На главную -> MyLDP -> Тематический каталог ->

Система Asterisk

Глава 1 из книги "Архитектура приложений с открытым исходным кодом", том 1.

Оригинал: Asterisk, глава из книги "The Architecture of Open Source Applications" том 1.
Автор: Russell Bryant
Дата публикации: 2012 г.
Перевод: Н.Ромоданов
Дата перевода: август 2013 г.

Creative Commons. Перевод был сделан в соответствие с лицензией Creative Commons. С русским вариантом лицензии можно ознакомиться здесь.

Asterisk [1] является платформой с открытым исходным кодом, распространяемой по лицензии GPLv2, которая предназначена разработки приложений телефонии. Если кратко, то это серверное приложение, с помощью которого можно делать вызовы, можно принимать вызовы и можно осуществлять специальную обработку телефонных вызовов.

Проект был запущен Марком Спенсером (Mark Spencer) в 1999 году. У Марка была компания Linux Support Services (оказывающая услуги по поддержке Линукс) и ему нужна была телефонная система, которая бы помогала вести его бизнес. У него не было достаточно денег на покупку готовой системы, поэтому он просто сделал свою собственную. По мере того, как росла популярность системы Asterisk, интересы компании Linux Support Services сместились в сторону проекта Asterisk и компания Linux Support Services была переименована в компанию Digium, Inc.

Название системы Asterisk пошло от названия символа «*» («звездочка», на английском языке - «asterisk»), который в системе Unix является универсальным символом. Целью проекта Asterisk было предоставить возможность делать все, что необходимо в телефонии. Продвигаясь к этой цели, система Asterisk теперь поддерживает длинный список технологий, применяемых для осуществления и приема телефонных вызовов. К ним относятся многие протоколы VoIP (Voice over IP - голос поверх IP), а также как аналоговые, так и цифровые подключения к традиционным телефонным сетям общего пользования PSTN (Public Switched Telephone Network). Одним из главных преимуществ системы Asterisk является ее способность осуществлять в системе или получать из системы вызовы различных типов.

Поскольку из системы Asterisk можно делать телефонные вызовы и их можно в системе принимать, есть также большое количество дополнительных возможностей, которые можно выбрать для обработки телефонных вызовов. Некоторые возможности, такие как голосовая почта, реализованы с помощью больших предварительно встроенных приложений общего назначения. Есть другие меньшие возможности, например, воспроизведение звуковых файлов, чтение клавиш с цифрами или распознавание речи, которые можно объединять друг с другом для создания собственных приложений голосовой обработки.

1.1. Основные архитектурные концепции

В этом разделе обсуждаются некоторые архитектурные концепции, являющиеся важными для всех частей системы Asterisk. Эти концепции являются фундаментом архитектуры Asterisk.

1.1.1. Каналы

Канал (channel) в Asterisk представляет собой соединение между системой Asterisk и некоторым конечным телефонным устройством (рис.1.1). Наиболее общим примером является случай, когда телефон отправляет телефонный вызов в систему Asterisk. Такое соединение будет представлено в виде в отдельного канала. В коде Asterisk канал существует как экземпляр структуры данных {ast_channel.

Рис.1.1: Одиночный вызов, представленный отдельным каналом

1.1.2. Соединение каналов типа «мост»

Наверное, гораздо более знаком сценарий вызова, когда соединение осуществляется между двумя телефонами: человек, использующий телефон А, звонит человеку, имеющему телефон В. В этом сценарии вызовов есть два конечных телефонных устройства, подключенных к системе Asterisk, поэтому для этого вызова существуют два канала (рис.1.2).

Рис.1.2: Два звена вызова, представленных двумя каналами

Когда каналы системы Asterisk соединяются таким образом, то такое соединение между каналами называется соединением типа «мост» (channel bridge). Создание моста является действием по соединению каналов вместе с целью передачи между ними медиаданных. Даже в случае, когда для каждой вызова с конечного устройства есть более одного потока медиаданных (например, аудио и видео данных), в системе Asterisk используется только единственный канал. На рис.1.2, где показаны два канала для телефонов А и В, на соединение типа «мост» возложена обязанность передачи медиаданных, идущих из телефона А в телефон В и, аналогичным образом, передачи медиаданных, идущих из телефона В в телефон А. Система Asterisk выступает в роли посредника по передаче всех потоков медиаданных. Не разрешено ничего, что не определено в системе Asterisk и над чем в системе Asterisk нет полного контроля. Это означает, что система Asterisk может делать записи, манипулировать с аудио-сообщениями и производить преобразования между форматами, относящимися к различным технологиям.

Когда происходит соединение двух каналов с помощью моста, то есть два способа, с помощью которых это можно сделать: создать универсальный мост (generic bridge) или нативный мост (native bridge). Универсальный мост является соединением, которое может работать независимо от того, какие технологии используются внутри канала. Он передает все аудио-сообщения и сигналы через абстрактный интерфейс, имеющийся в системе Asterisk. Хотя это наиболее гибкий способ создания мостового соединения, он является также наименее эффективным из-за того, что есть несколько уровней абстракций, которые требуется использовать с тем, чтобы выполнить задачу. Пример универсального моста приведён на рис.1.2.

Решение о выборе универсального или нативного мостового соединения делается с помощью сравнения каналов в тот момент, когда они соединяются мостом. Если для обоих каналов указывается, что в них поддерживается один и тот же способ нативного мостового соединения, то именно он и будет использоваться. В противном случае будет использован универсальный способ соединения типа «мост». Для того чтобы определить, поддерживается ли в обоих каналах один и тот же нативный способ мостового соединения, применяется простая функция, написанная на языке C, в которой выполняется сравнение указателей. Естественно, что это не самый элегантный способ, но мы еще ни разу не сталкивались с какими-либо случаями, когда этого для наших целей оказывалось недостаточным. Функция создания нативных мостовых соединений каналов будет более подробно рассматриваться в разделе 1.2. На рис.1.3 показан пример нативного мостового соединения.

Рис.1.3. Пример нативного соединения типа «мост»

1.1.3. Фреймы

Передача данных внутри кода Asterisk во время осуществления вызова происходит с использованием фреймов (frames); каждый фрейм является экземпляром структуры данных ast_frame. Фреймы могут быть либо медиафреймами (media frames), либо сигнальными фреймами (signalling frames). Во время обычного телефонного вызова звонка через систему будет передаваться поток медиафреймов, содержащих аудио данные. Сигнальные фреймы используются для передачи сообщений о событиях, касающихся вызова, например, о нажатии кнопки с цифрой, о переводе вызова в режим ожидания или о завершении вызова.

Список имеющихся фреймов определен статически. Каждый фрейм маркируется числом, в котором закодирован тип и подтип. Полный список можно найти в исходном коде в файле include/asterisk/frame.h; ниже приведено несколько примеров:

1.2. Компонентные абстракции системы Asterisk

Asterisk является приложением с высокой степенью модульности. Есть базовая часть приложения, которая собирается в каталоге исходного кода main/. Но она, сама по себе, будет мало чем для вас полезной. Базовая часть приложения выступает, прежде всего, в роли реестра модулей. В базовой части также есть код, в котором указано, как соединять все абстрактные интерфейсы вместе для того, чтобы можно было пропускать через систему телефонные вызовы. Конкретные реализации этих интерфейсов будут регистрироваться модулями, загружаемыми на этапе выполнения программы.

Когда запускается базовая часть приложения, то по умолчанию загружаются все модули, найденные в предопределенных каталогах файловой системы. Этот подход был выбран как самый простой. Тем не менее, есть отдельный конфигурационный файл, в котором можно уточнить, какие модули и в каком порядке следует загружать. Из-за этого конфигурирование системы несколько усложняется и для такого способа настройки требует немного больше времени, но появляется возможность указывать, какие модули загружать не нужно. Преимущество этого подхода состояло, прежде всего, в экономии памяти, используемой приложением. Однако это также хорошо для соблюдения некоторого уровня безопасности. Лучше не загружать модуль, у которого есть возможность осуществлять соединения в сети, в том случае, если этот модуль в действительности не нужен.

После того, как модули будут загружены, в базовой части Asterisk происходит регистрация всех компонентных абстракций, реализованных в этих модулях. Есть большое количество типов интерфейсов, которые можно реализовать внутри модулей и зарегистрировать в базовой части Asterisk. С помощью модуля можно регистрировать столько различных интерфейсов, сколько необходимо. Обычно в один модуль группируются интерфейсы, функционально связанные друг с другом.

1.2.1. Драйверы каналов

Из всех интерфейсов, имеющихся в системе Asterisk, интерфейс драйверов каналов (channel driver) является наиболее сложным и наиболее важным. В API каналов системы Asterisk предоставляется абстракция телефонного протокола, что позволяет пользоваться всеми другими возможностями системы Asterisk независимо от того, какой конкретно используется телефонный протокол. На данный компонент возлагается обязанность осуществлять преобразование между абстракцией канала Asterisk и конкретными особенностями телефонной технологии, реализация которой скрыта с помощью этой абстракции.

Интерфейсом драйверов каналов Asterisk является интерфейс ast_channel_tech. В нем определяется набор методов, которые должны быть реализованы в драйвере канала. Первым методом, который должен быть реализован в драйвере канала, является метод фабрики ast_channel, который представляет собой метод requester объекта ast_channel_tech. Когда создается канал Asterisk для входящего или исходящего телефонного вызова, то с типом канала ассоциируется реализация объекта ast_channel_tech, который для этого вызова должен создавать конкретный экземпляр объекта ast_channel и осуществлять его инициализацию.

Когда объект ast_channel создан, он будет ссылаться на объект ast_channel_tech, с помощью которого он был создан. Есть много других операций, которые должны обрабатываться вполне конкретным способом, зависящем от технологических особенностей. Когда в канале ast_channel должны выполняться такие операции, то их выполнение происходит через обращение к соответствующему методу в объекте ast_channel_tech. На рис.1.2 показаны два канала в системе Asterisk. На рис.1.4 видно, как уровни абстракции и конкретные реализации вписываются в архитектуру Asterisk.

Рис.1.4: Слой технологической реализации канала и абстрактный слой канала

Наиболее важными методами в ast_channel_tech являются следующие:

После того, как вызов завершится, код абстрактного канала, который работает в базовой части Asterisk, обратится к функции обратного вызова ast_channel_tech hangup и уничтожит объект ast_channel.

1.2.2. Приложения планирования прохождения вызовов (dialplan)

Администраторы системы Asterisk настраивают маршруты прохождения вызовов с помощью плана Asterisk dialplan, который находится в файле /etc/asterisk/extensions.conf. Dialplan состоит из наборов правил, которые называются расширениями (extensions). Когда в систему поступает новый телефонный вызов, набранный номер используется для того, чтобы найти в dialplan-е расширение, которое должно использоваться для обработки данного вызова. В расширении указывается список приложений dialplan, которые должны быть выполнены для данного канала. Приложения, доступные для выполнения в dialplan-е, регистрируются в реестре приложений. Этот регистр заполняется во время загрузки модулей в систему.

В состав Asterisk входит почти две сотни приложений. Определение приложения очень свободное. В приложениях для взаимодействия с каналами могут использоваться любые внутренние интерфейсы API, имеющиеся в системе Asterisk. Некоторые приложения выполняют одну задачу, например, приложение Playback, которое для вызывающего проигрывает звуковой файл. Другие приложения вовлечены вовлечены в работу системы в большей степени и выполняют очень большое количество операций, как, например, приложение Voicemail.

Благодаря тому, что используется Asterisk dialplan, несколько приложений можно объединять вместе для того, чтобы можно было выполнять специальную обработку вызовов. На случай, когда необходима более специализированная настройка, выходящая за рамки возможностей языка, предоставляемого в dialplan, есть скриптовые интерфейсы, которые позволяют выполнять специализированную обработку вызовов с использованием любого языка программирования. Даже в тех случаях, когда используются такие скриптовые интерфейсы с другими языками программирования, можно при взаимодействии с каналом продолжать обращаться к приложениям dialplan.

Прежде, чем мы перейдем к примеру, давайте посмотрим на синтаксис Asterisk dialplan, в котором обрабатываются вызовы с номером 1234. Заметьте, что номер 1234 выбран произвольным образом. Здесь вызываются три приложения dialplan. Первое, отвечающее на вызов. Следующее, проигрывающее звуковой файл. Заключительное, завершающее вызов.

; Определение правил, используемых в случае набора номера 1234.
;
exten => 1234,1,Answer()
    same => n,Playback(demo-congrats)
    same => n,Hangup()

Ключевое слово exten используется для определения расширения. В правой части строки exten цифры 1234 означают, что мы определяем правила для случая набора номера 1234. Следующая 1 означает, что это первый шаг, который нужно сделать, когда будет набран данный номер. Наконец, Answer указывает, что система должна ответить на вызов. Следующие две строки, которые начинаются с ключевого слова same, являются правилами для последнего расширения, для которого было задано определения, в данном случае — для 1234. Если кратко, то n означает, что делается следующий шаг. В последнем элементе каждой из этих строк, определяется, какое действие должно быть выполнено.

Ниже приведен еще один пример использования Asterisk dialplan. В этом случае система ответит на входящий вызов. Будет воспроизведен звуковой сигнал (beep), а затем в переменной DIGITS будет запомнено до четырех нажатых клавиш. Затем запомненные значения клавиш будут прочитаны и переданы вызывающей стороне. Наконец, вызов будет завершен.

exten => 5678,1,Answer()
    same => n,Read(DIGITS,beep,4)
    same => n,SayDigits(${DIGITS})
    same => n,Hangup()

Как уже упоминалось ранее, определение приложения является весьма свободным – зарегистрированный прототип функции очень простой:

int (*execute)(struct ast_channel *chan, const char *args);

Однако в действительности, в реализациях приложений могут использоваться все интерфейсы API, которые есть в include/asterisk/.

1.2.3. Функции dialplan

В большинстве приложений dialplan используется строка аргументов. Хотя некоторые из этих значений можно задать жестко, для того, чтобы обеспечить большую динамику поведения, вместо них можно пользоваться переменными. В следующем примере показан фрагмент dialplan, в котором устанавливается значение переменной, а затем с помощью приложения Verbose её значение выдается в командную строку Asterisk.

exten => 1234,1,Set(MY_VARIABLE=foo)
    same => n,Verbose(MY_VARIABLE is ${MY_VARIABLE})

Функции dialplan вызываются с использованием точно такого же синтаксиса, как в предыдущем примере. Модули Asterisk могут регистрировать функции dialplan, с помощью которых будет собираться некоторая информация и передаваться в dialplan. Либо, наоборот, с помощью этих функций можно извлекать информацию dialplan и выполнять действия с учетом этой информации. Хотя функции dialplan могут устанавливать или получать мета данные, используемые каналом, они, как правило, не выдают никаких сигналов и не выполняют обработку медиа контента. Эта работа возлагается на приложения dialplan.

В следующем примере демонстрируется использование функции dialplan. Во-первых, в командную строку Asterisk выводится идентификатор CallerID текущего канала. Затем, с помощью приложения SET изменяется значение CallerID. В этом примере, Verbose и SET являются приложениями маршрутизации, а CALLERID является функцией.

exten => 1234,1,Verbose(The current CallerID is ${CALLERID(num)})
    same => n,Set(CALLERID(num)=<256>555-1212)

В данном случае функция dialplan используется вместо обычной переменной, поскольку информация CallerID хранится в структуре данных в экземпляре ast_channel. В коде функции dialplan известно, как этой структуре присваивать значения и как из нее значения извлекать.

В следующем примере использования функции dialplan показано, как добавлять специальную информацию в журналы вызовов, называемыми записями CDR (Call Detail Records или подробные записи о вызове). Функция CDR позволяет получать записи с подробной информацией о вызове, а также добавлять специальную информацию.

exten => 555,1,Verbose(Time this call started: ${CDR(start)})
    same => n,Set(CDR(mycustomfield)=snickerdoodle)

1.2.4. Трансляторы кодеков

В мире VOIP используется много различных кодеков, применяемых для кодирования медиа информации, пересылаемой по сети. Разнообразие вариантов обусловлено компромиссами, касающимися качества передаваемой медиа информации, уровня загрузки процессора и требований, связанных с пропускной способностью. В системе Asterisk поддерживается множество различных кодеков и известно, как, при необходимости, осуществлять между ними преобразование.

Когда устанавливается соединение, Asterisk для того, чтобы не надо было делать преобразований, будет пытаться сделать так, чтобы на двух конечных устройствах использовались одинаковые медиакодеки. Однако, это возможно не всегда. Даже если используются одинаковые кодеки, все равно может потребоваться преобразование. Например, если система Asterisk настроена так, что когда аудиосигнал проходит через систему, требуется какая-нибудь его обработка (например увеличение или уменьшение уровня громкости), то системе Asterisk прежде, чем она сможет выполнить обработку сигнала, потребуется перекодировать аудио сигнал в распакованные вариант. Система Asterisk также может быть сконфигурирована так, чтобы записывать телефонные вызовы. Если указанный в конфигурации формат записи отличается от того, который используется при вызове, то потребуется перекодировка.

Взаимодействие кодеков

То, как осуществляется выбор, какой кодек будет использоваться для медиа-потока, конкретно зависит от технологии, используемой для передачи вызова в систему Asterisk. В некоторых случаях, например, как вызов в традиционной телефонной сети (PSTN), вариантов выбора вообще нет. Однако в других случаях, особенно при использовании протоколов IP, выбирается такой способ, когда для обеих сторон указываются возможности и предпочтения и выбирается общий подходящий кодек.

Например, когда вызов посылается в систему Asterisk, то, в случае использования протокола SIP (протокол, наиболее часто используемый в сетях VOIP), процедура выбора кодека, в самом общем виде, будет выглядеть следующим образом:

  1. Конечное устройство посылает в систему Asterisk запрос на новый вызов с указанием списка кодеков, которые желательно использовать.
  2. Asterisk проверяет свой конфигурационный файл, настроенный администратором, в котором в порядке предпочтения указывается список кодеков, разрешенных к использованию. Asterisk выберет наиболее предпочтительный кодек (на основе своего собственного заранее сконфигурированного предпочтения), который указан в конфигурационном списке конфигурации Asterisk и который также указан как поддерживаемый в поступившем запросе.

Одна из областей, с которой Asterisk справляется недостаточно хорошо, является область использования более сложных кодеков, особенно видеокодеков. За последние десять лет процедура согласование кодеков существенно усложнилась. Нам нужно много что еще сделать с тем, чтобы можно было пользоваться новейшими аудио кодеками и чтобы была возможность поддерживать видеокодеки гораздо лучше, чем мы это делаем сегодня. Это один из главных приоритетов новых разработок для следующего крупного релиза системы Asterisk.

В модулях трансляторов кодеков предлагается одна или несколько реализаций интерфейса ast_translator. В трансляторе есть атрибуты исходного и целевого форматов. Также имеется функция обратного вызова, которая будет использоваться для преобразования части медиапотока из исходного формата в целевой. В ней совсем ничего неизвестно о концепции телефонного вызова. В этой функции только известно, как конвертировать мультимедийные данные из одного формата в другой.

Более подробную информацию об интерфейсе API трансляторов кодеков смотрите в include/asterisk/translate.h и в main/translate.c. Реализации абстракции трансляторов можно найти в каталоге codecs.

1.3. Потоки

Система Asterisk является очень «тяжелым» многопоточным приложением. Оно использует интерфейс API потоков POSIX для управления потоками и относящимися к ним сервисами, например, блокировками. Весь код системы Asterisk, взаимодействующий с потоками, сделан так, что в нем присутствует набор обверток, используемых для отладки. Большую часть потоков в Asterisk можно считать либо потоком мониторинга сети (Network Monitor Thread), либо канальным потоком (Channel Thread), который иногда также называется потоком АТС (автоматической телефонной станции), поскольку его первоначальное назначение состояло в выполнении в канале функции АТС.

1.3.1. Потоки мониторинга сети

Потоки мониторинга сети имеются в системе Asterisk в каждом из основных драйверов канала. На них возлагается обязанность вести наблюдение за сетью, к которой они подключены (независимо от того, будет ли это сеть IP или обычная телефонная сеть), а также за всеми поступающими вызовами или другими типами поступающих запросов. С их помощью также осуществляется первоначальная настройка соединения, например, аутентификация и проверка набранного номера. После того, как настройка вызова будет выполнена, потоки мониторинга сети создадут экземпляр канала (ast_channel) и запустят канальный поток, которые будет обрабатывать вызов в течение всего оставшегося времени существования вызова.

1.3.2. Канальные потоки

Как уже обсуждалось ранее, канал является фундаментальной концепцией в системе Asterisk. Каналы могут быть входящими или исходящими. Входящий канал создается тогда, когда в систему Asterisk поступает входящий вызов. Эти каналы являются именно теми, которые выполняются согласно описаниям dialplan системы Asterisk. Для каждого канала, который выполняется согласно dialplan, создается поток. Такие потоки называются канальными потоками.

Приложения dialplan всегда исполняются в контексте канального потока. Функции dialplan почти всегда исполняются таким же образом. С помощью асинхронного интерфейса, например, интерфейса командной строки Asterisk, можно читать и записывать функции dialplan. Тем не менее, всегда есть канальный поток, который является владельцем структуры данных ast_channel и управляет временем жизни объекта.

1.4. Сценарии вызовов

В двух предыдущих разделах мы рассмотрели важные интерфейсы компонентов системы Asterisk, а также ее потоковую модель выполнения. В этом разделе для того, чтобы продемонстрировать, как компоненты системы Asterisk компоненты совместно обрабатывают телефонные вызовы, будут подробно рассмотрены несколько наиболее распространенных сценариев вызовов.

1.4.1. Проверка голосовой почты

Одним из типичных сценариев является ситуация, когда некто звонит для того, чтобы проверить свою голосовую почту. Первым компонентом, которые участвует в этом сценарии, является драйвер канала. Драйвер будет ответственен за обработку входящего вызова, который появится в потоке мониторинга в драйвере канала. Это вызов может настраиваться по-разному в зависимости от технологии, используемой для пересылки вызова в систему. Следующим шагом настройки вызова является определение, кому отправляется вызов. Это обычно осуществляется по номеру, который набирает вызывающий. Однако, в некоторых случаях в наличии нет никакого конкретного номера, поскольку в технологии, используемой для пересылки вызова, нет спецификации, связанной с набранным номером. Примером этого может быть входящий вызов, поступающий по аналоговой телефонной линии.

Если драйвер канала определит, что для набранного номера в конфигурации Asterisk заданы расширения, определенные в dialplan-е (конфигурация маршрутизации вызова), то он создаст объект канала Asterisk (ast_channel) и создаст канальный поток. На этот канальный поток будет возложена основная обязанность обработки остальной части вызова (рис.1.5).

Рис.1.5.: Диаграмма последовательности настройки вызова

Главный цикл канального потока осуществляет выполнение dialplan-а. Он следует правилам, определенным для набранного расширения, и выполняет шаги, которые в них определены. Ниже приведен пример расширения, записанного в синтаксисе extensions.conf. В этом расширении указывается, что нужно ответить на вызов и, если кто-нибудь наберет номер *123, выполнить приложение VoicemailMain. Это то приложение, которое вызывается пользователем для проверки сообщений, оставшихся в голосовой почте.

exten => *123,1,Answer()
    same => n,VoicemailMain()

Когда канальный поток выполнит приложение Answer, Asterisk ответит на входящий вызов. Ответ на вызов требует специальной технологической обработки, поэтому в добавок к некоторой общей обработке ответа также вызывается функция обратного вызова answer, находящаяся в ассоциированной структуре ast_channel\tech, которая будет обрабатывать вызов. При этом может происходить отсылка по сети специального пакета поверх сети IP, сообщающего, что в аналоговой линии нужно снять трубку и т.д.

Следующим шагом для канального потока будет выполнение приложения VoicemailMain (рис.1.6). Это приложение предоставляется в виде модуля app_voicemail. Следует заметить, что хотя приложение Voicemail выполняет большой объем работы, необходимый при взаимодействии с вызовами, в нем ничего не известно о технологии, используемой для передачи вызова в систему Asterisk. Абстракция канала The Asterisk скрывает эти особенности от реализации приложения голосовой почты.

Есть целый ряд возможностей, которые предоставляются обратившемуся к своей голосовой почте. Однако все они, прежде всего, реализованы как операции чтения и записи звуковых файлов, выполняемые в ответ на ввод команд, поступающих от звонящего, которые, в основном, являются нажатием клавиш с цифрами. Сигналы о нажатия клавиш могут поступать в Asterisk различными способами. Но опять, с этими особенностями справляются драйверы каналов. Как только нажатие клавиши поступает в систему Asterisk, оно конвертируется в обобщенное событие о нажатии клавиши и передается в код приложения Voicemail.

Одним из важных интерфейсов в системе Asterisk, который мы уже рассматривали, является транслятор кодеков. Такие реализации кодеков важны для данного сценария вызова. Когда в коде голосовой почты потребуется вызывающему воспроизвести звуковой файл, аудиоформат в звуковом файле может не совпасть с аудиоформатом, используемым при коммуникации между системой Asterisk и вызывающим. Если потребуется преобразовать аудиоформат, то для того, чтобы из исходного формата получить целевой формат, будет создан последовательность преобразований (translation path), состоящая из одного или нескольких трансляторов кодеков.

Рис.1.6: Вызов приложения голосовой почты VoicemailMain

В некоторый момент вызывающий завершит свое общение с системой голосовой почты и повесит трубку. Драйвер канала обнаружит, что это произошло, и преобразует это событие в обобщенное сигнальное событие канала Asterisk. Код приложения Voicemail получит это сигнальное событие и закончит свое выполнение. Управление будет возвращено в основной цикл в канальном потоке, в результате чего можно будет продолжить выполнение dialplan-а. Поскольку в этом примере в dialplan-е нет ничего, что нужно обрабатывать, драйвер канала получит возможность специальный образом, зависящим от используемой технологии, обработать шаг отключения вызова, после чего объект ast_channel будет уничтожен.

1.4.2. Вызов с созданием «моста»

Еще одним довольно общим сценарием вызовов в системе Asterisk является вызов с созданием «моста» между двумя каналами. Это сценарий, когда один телефон через систему вызывает другой телефон. Первоначальный этап процесса настройки вызова аналогичен тому, который был рассмотрен в предыдущем примере. Отличия в обработке начинаются после того, как вызов был настроен и канальный поток начинает выполнение dialplan-а.

Следующий dialplan является простым примером, результатом выполнения которого будет вызов с использованием соединения типа «мост». Когда на телефоне будет набран номер 1234, то при использовании данного расширения dialplan выполнит приложение Dial, которое является основным приложением, используемым для инициации исходящего вызова.

exten => 1234,1,Dial(SIP/bob)

Аргумент, указываемый в приложении Dial, сообщает, что система должна создать исходящий вызов на устройство, обозначаемое как SIP/bob. Часть SIP этого аргумента указывает, что для доставки данного вызова должен использоваться протокол SIP. bob будет проинтерпретировано драйвером канала, который должен реализовывать протокол SIP, т.е. chan_sip. Если предположить, что в драйвере канала был должным образом сконфигурирован аккаунт с именем bob, то будет известно, как можно будет получить доступ к телефону Боба.

Приложение Dial запросит базовую часть системы Asterisk выделить новый канал, использующий идентификатор SIP/bob. Базовая часть запросит драйвер канала SIP выполнить специальную инициализацию, соответствующую данной технологии. Драйвер канала также инициирует процесс дозвона до телефона. Как только придет ответ на этот вызов, драйвер передаст события обратно в базовую часть Asterisk, которые будут приняты приложением Dial. Эти события могут говорить о том, что поступил ответ на вызов, что направление занято, что сеть перегружена, что вызов был отклонен по некоторой причине или о ряде других событий. В идеальном случае будет получен ответ на вызов. Тот факт, что на вызов был получен ответ, вернется обратно во входящий канал. Asterisk не ответит на входящий вызов до тех пор, пока не на исходящий вызов не поступит ответ. Ка только оба канала ответят на вызов, между ними будет установлено соединение типа «мост» (рис.1.7).

Рис.1.7: Блок-схема мостового вызова внутри обобщенной абстракции типа «мост»

Когда через мост произойдет соединения каналов, аудиопоток и сигнальные события будут передаваться между каналами до тех пор, пока не возникнет некоторое событие, ведущее к разрыву мостового соединения, например, когда на одной стороне повесят трубку. На рис.1.8 приведена диаграмма, демонстрирующая ключевые операции, выполняемые с аудиофреймом при использовании соединения типа «мост».

Рис.1.8: Диаграмма последовательности действий при обработке аудио фрейма для соединения типа «мост»

После того, как вызов будет завершен, процесс рассоединения будет похож на тот, что описан в предыдущем примере. Главное отличие здесь в том, что данном процессе участвуют два канала. Прежде, чем канальный поток прекратит свое существование, для обоих каналов будет выполнена специальная процедура рассоединения, обусловленная технологией, используемой в каждом из каналов.

1.5. Заключительные комментарии

Архитектуре системы Asterisk в настоящее время уже больше десяти лет. Однако фундаментальные концепции каналов и гибкой обработки вызовов, используемых в dialplan-е системы Asteris, по-прежнему позволяют поддерживать разработку сложных систем телефонии в отрасли, которая постоянно развивается. Одной из областей, с которой архитектура системы Asterisk не может достаточно хорошо справиться, является масштабирование системы на нескольких серверах. Сообщество разработки Asterisk разрабатывает в настоящее время сопутствующий проект, который называется Asterisk SCF (Scalable Communications Framework — Масштабируемый коммуникационный фреймворк), предназначенный для решения этих проблем масштабируемости. В ближайшие несколько лет мы ожидаем увидеть, что система Asterisk вместе с системой Asterisk SCF будет продолжать занимать все большую часть рынка телефонии, в том числе в области крупных систем.

Примечания

  1. http://www.asterisk.org/
  2. DTMF является сокращением Dual-Tone Multi-Frequency (режим двухтонального многочастотного набора). Это тональный сигнал, который посылается как телефонный аудиосинал, когда кто-нибудь на своем телефоне нажимает клавишу с цифрой.

2.1. Структура Audacity

Audacity состоит из слоев нескольких библиотек. Хотя для большей части нового кода, который программируется в Audacity, не требуется детального знания того, что именно происходит в этих библиотеках, знакомство с их интерфейсом API, и то, что они делают, является важным. Двумя наиболее важными библиотеками являются библиотека PortAudio, в которой предоставлен низкоуровневый аудио интерфейс, обеспечивающий кросс-платформенность, и библиотека wxWidgets, в которой предоставлен графический компонент кросс-платформенности.

Когда читаете код Audacity, полезно понимать, что важной является только часть кода. Библиотеки добавляют много необязательных функций, хотя те, кто ими пользуется, не считают, что они необязательные. Например, имея свои собственные встроенные звуковые эффекты, Audacity поддерживает LADSPA ( Linux Audio Developer's Simple Plugin API — интерфейс API простых плагинов разработчиков Linux Аудио) для динамически загружаемых плагинов звуковых эффектов. Интерфейс VAMP API в Audacity делает то же самое для плагинов, которые анализируют аудио. Без этих интерфейсов Audacity была бы менее функционально насыщенной, но она абсолютно не зависит от этих возможностей.

Другими необязательным библиотеками, используемыми Audacity, являются libFLAC, libogg и libvorbis. С их помощью реализуются различные форматы сжатия аудиосигнала. Формат MP3 реализуется за счет динамической загрузки библиотеки LAME или Ffmpeg. Лицензионные ограничения не позволяют встраивать эти очень популярные библиотеки сжатия.

Лицензирование стоит за некоторыми другими решениями, относящимся к библиотекам и структурам Audacity. Например, поддержка плагинов VST не встраивается из-за лицензионных ограничений. Нам бы также хотелось в некоторых местах нашего использовать очень эффективный код FFTW , реализующий быстрое преобразование Фурье. Однако, мы предоставляем это только как дополнительный вариант для тех, кто компилирует Audacity самостоятельно, а вместо этого возвращаемся к версии, которая встроена в наш код и работает чуть медленнее. До тех пор, пока в Audacity можно пользоваться плагинами, можно и следует утверждать, что в Audacity не должен использоваться пакет FFTW. Авторы FFTW не хотят, чтобы их код был доступен в виде обычного сервиса в каком-либо другом коде. Так, архитектурное решение о поддержке плагинов приводит к компромиссу, относящемуся к тому, что мы можем предложить. Он позволяет в наших готовых исполняемых файлах использовать плагины LADSPA, но запрещает нам использовать FFTW.

На архитектуре сказались также и наши мысли о том, как лучше использовать наше ограниченное время, уделяемое разработке. С небольшой командой разработчиков, у нас нет, например, ресурсов, чтобы произвести углубленный анализ лазеек, связанных с безопасностью, который делают группы, работающие над Firefox и Thunderbird. Тем не менее, мы не хотим, чтобы Audacity было средством, позволяющем обходить брандмауэр, поэтому у нас есть правило вообще не иметь в Audacity входящих или исходящих соединений TCP/IP. Отсутствие соединений TCP/IP позволяет избежать многих проблем безопасности. Наше понимание того, что мы обладаем ограниченными ресурсами, ведет нас к лучшим проектным решениям. Это помогает нам отказываться от функций, на разработку которых нам придется тратить слишком много времени, и сосредотачиваться на том, что наиболее важно.

Аналогичную озабоченность, касающаяся времени разработки, связана со скриптовыми языками. Мы хотим писать сценарии, но код, реализующий эти языки, не должен быть в Audacity. Нет смысла создавать в Audacity копии всех скриптовых языков лишь для того, чтобы дать пользователям возможность выбрать тот, который им нужен [1]. Вместо этого нами реализована возможность писать скрипты с помощью единственного модуля плагина и конвейера, что мы рассмотрим позже.

Рис.2.1: Слои в Audacity

На рис.2.1 показаны несколько слоев и модулей, имеющиеся в Audacity. На схеме в wxWidgets выделены три важных классах, играющих важную роль в Audacity. Мы, опираясь на абстракциях низкого уровня, строим абстракции более высокого уровня. Например, система блок-файлов BlockFile отображается в объекты wxFile, имеющиеся в wxWidgets. Возможно, на некотором этапе, имеет смысл выделить блок-файлы BlockFile, графическую оболочку ShuttleGUI и команды обработки в промежуточную библиотеку с ее собственными правами. Это должно стимулировать нас сделать их более обобщенными.

Ниже в диаграмме показана узкая полоска «Слои реализации конкретных платформ». Оба слоя wxWidgets и PortAudio являются слоями абстракции ОС. В них обоих есть код, позволяющий, в зависимости от целевой платформы, делать выбор между различными реализациями.

В категорию «Другие библиотеки поддержки» включен широкий набор библиотек. Достаточно интересно то, что в многих из них используются динамически загружаемые модули. Эти динамические модули ничего не знают о wxWidgets.

На платформе Windows мы, как правило, компилируем Audacity в один монолитный исполняемый файл, причем код приложений wxWidgets и Audacity входит в тот же самый исполняемый файл. В 2008 году мы перешли на использование модульной структуры и используем wxWidgets как отдельную DLL. Это позволяет во время выполнения программы загружать дополнительные DLL с необязательными функциями только тогда, когда в этих DLL непосредственно используются возможности wxWidgets. Плагины, которые на схеме подключены выше пунктирной линии, могут использовать wxWidgets.

Решение об использовании DLL для wxWidgets имеет свои минусы. Размер дистрибутива теперь стал больше, частично из-за того, что в DLL есть много неиспользуемых функций, которые раньше были просто удалены. Загрузка Audacity также происходит дольше, поскольку каждая DLL загружается отдельно. Но преимуществ больше. Мы надеемся, что модульность даст нам те же преимущества, какие есть в Apache. Мы видим, что модули позволяют ядру Apache быть очень стабильными, а поддержка экспериментов, специальных возможностей и новых идей происходит в модулях. Модули проходят очень долгий путь, противодействуя искушению изменить направление развития проекта. Мы думаем, что это для нас это было очень важным архитектурным решением. Мы ожидаем от этого выиграть, но еще мы этого не достигли. Предоставление функций wxWidgets в таком виде является лишь первым шагом, и мы должно много что сделать для того, чтобы получить более гибкую модульную систему.

Структура программы такой, как Audacity, заранее четко не разрабатывается. Она разрабатывается по ходу дела. По большому счету, архитектура, которая у нас есть, для нас подходит. Нам приходится сражаться с архитектурой, когда мы пытаемся добавлять новые возможности, которые затрагивают многие файлы с исходным кодом. Например, в настоящее время в Audacity стерео и моно дорожки обрабатываются специальным образом. Если бы вы хотели изменить Audacity так, чтобы обрабатывать объемный звук, то вам бы потребовалось вносить изменения во многие классы в Audacity.

За пределами стереозвука: история о GetLink

Audacity никогда не имела абстракции числа каналов. Вместо этой абстракции имелись ссылки на аудиоканалы. Есть функция GetLink, которая возвращает другой парный звуковой канал в паре, если их два, или возвращает NULL, если дорожка монофоническая. Код, в котором используется GetLink, обычно выглядит точно так, как если бы его первоначально написали для монофонического сигнала, а затем использовали проверку (GetLink() != NULL) с тем, чтобы обрабатывать стереосигнал. Я не уверен, что крд был написан именно так, но подозреваю, что так это и произошло. Нет циклического использования GetLink по всем каналам, находящихся в связанном списке. При рисовании, микшировании, чтении и записи — везде используется проверка случая обработки стереосигнала, а не обобщенный код, который может работать на n каналах, где n, скорее всего, должно быть равно одному или двум. Чтобы перейти к более обобщенному коду, вам потребуется внести изменения приблизительно в 100 мест, где вызывается функция GetLink, изменив, по меньшей мере, 26 файлов.

Легко выяснить, что найти вызовы GetLink и внести необходимые изменения с тем, чтобы исправить эту «проблему», не так уж сложно, как это может показаться на первый взгляд. История с GetLink не о структурных изъянах, которые трудно исправить. Она, скорее всего, иллюстрирует то, как относительно небольшой изъян может переместиться в различные части коде, если этому не воспрепятствовать.

Если оглянуться назад, то было бы хорошо сделать функцию GetLink приватной и предложить итератор для перебора всех каналов в дорожке. Это позволило бы отказаться от большого количества специальных случаев кода, предназначенных для стереосигналов, и, в то же время, создать код, в котором список аудио каналов использовался бы независимо от того, как реализован список.

Более модульный проект, вероятно, позволит нам лучше скрыть внутреннюю структуру. Когда мы определяем и расширяем внешние интерфейсы API, нам требуется более внимательно изучать функции, которые нам предлагаются. В результате наше внимание сосредотачивается на абстракциях, а мы не хотим, чтобы они присутствовали во внешнем API.

2.2. Библиотека графического интерфейса wxWidgets

Наиболее важной отдельно библиотекой, которую программисты Audacity используют для создания графического пользовательского интерфейса, является библиотека wxWidgets, в которой предлагаются такие вещи, как кнопки, движки, флаги, обычные и диалоговые окна. В ней реализована большая часть кросс-платформенных функций визуализации. В библиотеке wxWidgets есть свой собственный класс строк wxString, в ней есть кросс-платформенные абстракции потоков, файловых систем и шрифтов, а также механизм для локализации для других языков, которыми мы пользуемся. Мы советуем тем, кто только что присоединился к разработке Audacity, загрузить wxWidgets, скомпилировать некоторые из примеров,которые поставляются с этой библиотекой, и поэкспериментировать с ними. Библиотека wxWidgets является сравнительно тонким слоем, построенным над базовыми объектами графического пользовательского интерфейса, предоставляемыми операционной системой.

Для создания сложных диалогов в wxWidgets предлагаются не только отдельные элементы, но также специальные механизмы управления размерами элементов и их положением (sizers). Они позволяют получить более красивый внешний вид, чем при использовании только абсолютно фиксированных позиций графических элементов. Если размер виджета изменен либо непосредственно пользователем, либо, скажем, из-за изменения размера шрифта, на другой, изменение расположения элементов в диалоговом окне произойдет очень естественно. Такие механизмы очень важны для кросс-платформенных приложений. Без них нам, возможно, приходилось бы пользоваться для каждой платформы специальными вариантами диалоговых окон.

Часто дизайн таких диалоговых окон помещается в файл ресурсов, который читается программой. Однако в Audacity мы строим диалоговые окна в программе исключительно в виде последовательности вызовов wxWidgets. В результате достигается максимальная гибкость: то есть, диалоговые окна, конкретное содержимое и поведение которых будет определяться кодом уровня приложения.

В свое время вы могли бы найти в Audacity места, где было ясно, что исходный код для создания графического пользовательского интерфейса был сгенерирован с использованием графических инструментов создания диалоговых окон. Эти средства помогли нам получить базовый проект. Со временем для того, чтобы добавить новые функции, базовый код был повсюду изменен, в результате чего во многих местах новые диалоговые окна создавались с помощью копирования и модификации существующих кода с некоторыми изменениями, касающихся диалоговых окон.

После нескольких лет такой разработки мы обнаружили, что значительная часть исходного кода Audacity, прежде всего диалоговые окна для задания пользовательских настроек, состоит из запутанного и повторяющегося кода. Этот код, хотя он был прост с точки зрения того, что делал, следовать ему было удивительно трудно. Часть проблемы состояла в том, что порядок, в котором были построены диалоговые окна, был абсолютно произвольным: мелкие элементы объединялись в более крупные и, в конечном итоге, в полные диалоговые окна, но порядок, в котором элементы создавались в коде, не соответствовал (что и не требовалось) тому порядку, в котором элементы располагались на экране. Код был излишне большого размера и было много повторений. Был код, являющийся частью графического пользовательского интерфейса, который передавал данные из пользовательских настроек, запомненных на диске, в промежуточные переменные, код, который передавал значения промежуточных переменных в изображаемый графический пользовательский интерфейс, код, который передавал данные из изображаемого графического пользовательского интерфейса в промежуточные переменные, и код, который передавал промежуточные переменные в пользовательские настройки, запоминаемыми на диске. В коде был вставлен комментарий //this is a mess (// здесь беспорядок), но прошло достаточно много времени до того момента, когда с этим было что-то сделано.

2.3. Слой ShuttleGui

Решить проблему с запутанным кодом позволил новый класс, ShuttleGui, который существенно сократил число строк кода, необходимого для спецификации диалоговых окон, что сделало код более удобным для чтения. Класс ShuttleGui представляет собой дополнительный слой между библиотекой wxWidgets и Audacity. Его задача заключается в передаче между ними информации. Ниже приведен пример, который, с конце концов, становится элементами графического пользовательского интерфейса, изображенного на рис.2.2.

ShuttleGui S;
// GUI Structure
S.StartStatic("Some Title",…);
{
    S.AddButton("Some Button",…);
    S.TieCheckbox("Some Checkbox",…);
}
S.EndStatic();

Рис.2.2: Пример диалогового окна

В этом коде определяется статическая область в диалоговом окне и область, в которой находится кнопка и чекбокс. Соответствие между кодом и диалоговым окном должно быть очевидным. StartStatic и EndStatic являются парными вызовами. Другими подобными согласованными парами являются StartSomething/EndSomething, которые используются для управления другими элементами компоновки диалогового окна. Фигурные скобки и отступы, которые с ними используются, требуются не для того, чтобы код был правильным. Мы приняли соглашение о том, чтобы их добавлять для того, чтобы сделать более очевидными структуру и особенности согласования парных вызовов. Это действительно упрощает чтение больших примеров.

Показанный исходный код не только создает диалоговое окно. Код, идущий после комментария "//GUI Structure", также можно использовать для обмена данными между диалоговым окном и пользовательскими настройками, запоминаемыми на диске. Ранее для этого пришлось бы писать большой объем кода. В настоящее время этот код пишется только один раз, и скрыт в классе ShuttleGui.

В Audacity есть и другие расширения для базовых виджетов wxWidgets. В Audacity есть свой собственный класс для управления панелями инструментов. Почему не использовать класс панелей инструментов, встроенный в wxWidget? Причина историческая: панели инструментов Audacity были написаны до того, как стал использоваться wxWidgets, в котором предоставлен класс панелей инструментов.

2.4. Панель TrackPanel

Основной панелью в Audacity, на которой отображаются аудиодорожки, является панель TrackPanel. Это специальный управляющий компонент, который рисуется с помощью Audacity. Панель состоит из таких компонентов, как панели меньшего размера с информацией о дорожке, линейка для изменения развертки, линеек для изменения амплитуды, а также дорожек, на которых могут отображаться сигналы, спектры или текстовые метки. С помощью операции перетаскивания, выполняемой мышью, можно изменить размер и местоположение дорожки. Дорожки, у которых есть текстовые метки, пользуются нашей собственной реализацией редактируемого текстового поля, а не той, что есть в системе. Вы можете решить, эти дорожки и линейки, располагаемые панели, должны быть компонентами wxWidgets, но это неверно.

Рис.2.3: Интерфейс Audacity с элементами панели, текстовыми метками и аудиодорожками

На скриншоте, показанном на рис.2.3, приведен пользовательский интерфейс Audacity. Все компоненты, которые помечены, являются специальными компонентами Audacity. Что касается подключен wxWidgets, то в TrackPanel имеется один компонент wxWidget.Все позиционирование и перерисовку обеспечивает код Audacity, а не wxWidgets.

Способ, с помощью которого все эти компоненты собраны друг с другом для создания TrackPanel, действительно ужасен. Ужасен именно код; конечный результат, который пользователь видит, выглядит просто отлично. Код графического пользовательского интерфейса и код, относящийся конкретно к приложению, смешаны вместе, а не отделены друг от друга. В хорошем проекте только в коде приложения должно быть известно о левом и правом каналах, децибелах, отключение звука и режиме соло. Элементы графического интерфейса должны быть независимы от элементов приложения и должна быть возможность их повторного использования в приложении, не относящегося к аудиозаписям. Даже чисто графические части панели TrackPanel являются смешением специальных случаев кода, в котором указаны абсолютные позиции и размеры, и недостаточно абстракций. Было бы гораздо лучше, понятней и более естественный, если бы эти специальные компоненты были самодостаточными элементами графического пользовательского интерфейса и если они использовали значения размера точно также, как это делается в wxWidgets.

Чтобы получить такую панель TrackPanel, нам в wxWidgets нужен другой механизм использования значений размеров, который позволял перемещать и изменять размеры дорожек или, впрочем, любого другого виджета. В wxWidgets это делается недостаточно гибко. Мы бы могли использовать этот механизм в других местах, что дало бы нам дополнительные преимущества. Мы могли бы пользоваться им в панелях инструментов, где находятся кнопки, что позволило бы более просто выбирать положения кнопок, перетаскивая их с мета на месте с помощью мыши.

Для этого была проделана определенная исследовательская работа, но в недостаточном объеме. Некоторые эксперименты с созданием графических компонентов полноценных элементов wxWidgets выявили следующую проблему: подобный механизм уменьшает возможность управлять перерисовкой виджетов, в результате возникает мерцание в тех случаях, когда изменяются размеры или происходит перемещение компонентов. Нам потребуется существенно изменить wxWidgets, чтобы избавиться от мерцания при перерисовке и еще лучше отделить операции, выполняемые при изменении размеров, от операций, необходимых при перерисовке.

Для этого была проделана определенная исследовательская работа, но в недостаточном объеме. Некоторые эксперименты с созданием графических компонентов полноценных элементов wxWidgets выявили следующую проблему: подобный механизм уменьшает возможность управлять перерисовкой виджетов, в результате возникает мерцание в тех случаях, когда изменяются размеры или происходит перемещение компонентов. Нам потребуется существенно изменить wxWidgets, чтобы избавиться от мерцания при перерисовке и еще лучше отделить операции, выполняемые при изменении размеров, от операций, необходимых при перерисовке.

Лучшим решением является использование простого шаблона, по которому мы самостоятельно нарисуем легковесные виджеты, что позволит не иметь соответствующих объектов, потребляющих ресурсы оконной системы, и не нужно будет ими управлять. Нам бы хотелось использовать структуру, подобную механизму выбора размеров, имеющуюся в wxWidgets, и виджеты компонентов, и использовать похожий интерфейс API, компоненты которого не являются производными от классов wxWidgets. Мы должны переделать наш существующий код TrackPanel так, чтобы его структура стала более ясной. Если бы это было простое решение, то это было бы уже сделано, но то, что мы пришли к единому мнению, что, в конечном итоге, мы хотим именно получить, позволило нам продвинуться дальше от предыдущих попыток. Если обобщить наш нынешний подход, то требуется выполнить большой объем работы по проектированию и кодированию. Есть большой соблазн отказаться от сложного кода, который уже и так работает достаточно хорошо.

2.5. Библиотека PortAudio: запись и воспроизведение

PortAudio является аудио-библиотекой, которая дает Audacity возможность воспроизводить и записывать звук независимо от используемой платформы. Без нее Audacity не сможет использовать звуковую карту устройства, на котором оно работает. В PortAudio предоставляются кольцевые буферы, средства, позволяющие изменять частоту дискретизации при воспроизведении/записи, и, самое главное, предоставляется интерфейс API, который скрывает различия между аудиообработкой на платформах Mac, Linux и Windows. В PortAudio есть файлы альтернативных реализаций для поддержки этого интерфейса API для каждой из платформ.

Мне никогда не нужно было копаться в PortAudio для того, чтобы разобраться, что там внутри происходит. Однако, полезно знать, как происходит взаимодействие с PortAudio. Audacity принимает пакеты данных от PortAudio (запись) и посылает их в PortAudio (воспроизведение). Стоит взглянуть на то, как именно происходит отправка и получение пакетов и как это согласуется с чтением и записью на диск и с обновлением экрана.

В одно и то же время происходят несколько различных процессов. Некоторые происходят часто, передавая небольшие объемы данных, и реакция на них должна быть быстрой. Другие происходят реже, передавая большие объемы данных, и точное время, когда это происходит, менее критично. В результате между процессами возникает рассогласование и для его выравнивания используются буферы. Вторую часть картины составляет то, что мы имеем дело с аудио устройствами, жесткими дисками и экраном. Мы не идем глубже и должны работать с тем интефейсом API, который нам предоставлен. Хотя для нас было бы лучше, чтобы каждый из наших процессов выглядел одинакового, например, чтобы каждый из них работал через wxThread, но у нас нет такой возможности (рис. 2.4).

Рис.2.4: Потоки и буферы, используемые при воспроизведении и записи

Один аудио поток запускается кодом PortAudio и непосредственно взаимодействует с аудиоустройством. Это тот поток, который управляет записью и воспроизведением. Этот поток должен реагировать быстро, иначе пакеты будут потеряны. Поток, находящийся под управлением кода PortAudio, называется audacityAudioCallback, который, когда происходит запись, добавляет вновь поступившие небольшие пакеты к большему (в пять секунд) буферу захвата. При воспроизведении он берет небольшие кусочки данных из буфера воспроизведения, размер которого равен пяти секундам. Библиотека PortAudio ничего не знает о wxWidgets и поэтому этот поток, созданный PortAudio, является потоком pthread.

Второй поток запускается код в классе AudioIO в Audacity. При записи, AudioIO берет данные из буфера захвата и добавляет их в дорожки Audacity, которые, в конечном счете, будут отображаться на экране. Кроме того, когда будет добавлено достаточное количество данных, AudioIO записывает данные на диск. Этот же поток при воспроизведении аудиозаписей также выполняет операции чтения с диска. Здесь функция AudioIO::FillBuffers является ключевой функцией и, в зависимости от настроек некоторых логических переменных, обрабатывает как запись, так и воспроизведение. Важно, чтобы обоих направлениях работала одна функция. Когда происходит «программное воспроизведение», при котором вы накладываете одну запись на другую, которая была записана ранее, одновременно используются части - часть записи и часть воспроизведения, Поток AudioIO мы полностью отдали на откуп операциям ввода/вывода на диск, которые выполняются операционной системой. Во время чтения или записи на диск мы можем останавливаться на неопределенный промежуток времени. Мы не могли вставить эти операции чтения или записи в функцию audacityAudioCallback, поскольку она должна реагировать достаточно быстро.

Связь между этими двумя потоками происходит через общие переменные. Поскольку мы контролируем, какие потоки осуществляют записи в эти переменные, нам не требуются более дорогостоящие механизмы типа mutexe (механизм управления взаимодействующими процессами).

Как в случае воспроизведения, так и в случае записи, имеется дополнительное требование: Audacity также должна обновлять графический пользовательский интерфейс. Это наименее критичная по времени операция. Обновление происходит в основном потоке графического пользовательского интерфейса и выполняется по таймеру, который тикает двадцать раз в секунду. Этот тик таймера вызывает TrackPanel::OnTimer, и, если обнаружено, что необходимо обновление графического интерфейса, то эти обновления выполняются. Такой поток, обслуживающий графический пользовательский интерфейс, создается в wxWidgets, а не нашем собственном коде. Особенность его в том, что другие потоки не могут напрямую обновлять графический пользовательский интерфейс. Использование таймера для доступа к графическому пользовательскому интерфейсу для проверки того, нужно ли обновление экрана, позволяет сократить количество перерисовок до уровня, который приемлем для быстро работающих дисплеев и не требует от процессора слишком больших затрат на выдачу изображения.

Хорошим ли проектным решением является использование потока аудиоустройства, потока буфера/диска и потока графического пользовательского интерфейса с таймером, по которому происходят все эти перенаправления аудиоданных? Это специальное решение, представляющее собой три различных потока, которые не заданы в одном абстрактном базовом классе. Впрочем, такая специфика в значительной степени продиктована используемым нами библиотеками. Предполагается, что PortAudio создает собственный поток. Во фреймворке wxWidgets автоматически создается поток графического пользовательского интерфейса. Наша потребность в использовании потока заполнения буфера обусловлена тем, что нам нужно скорректировать несогласованность между достаточно частыми небольшими пакетами потока аудиоустройства и менее частыми, но большими пакетами дискового устройства. То, что мы используем эти библиотеки, дает нам определенные преимущества. Плата за использование библиотек состоит в том, что мы, в конечном итоге, пользуемся только теми абстракции, которые они нам предлагают. В результате мы копируем данные из одного места в памяти в другое в гораздо большем объеме, чем это необходимо. В быстрых коммутаторах данных, с которыми я работал, я видел чрезвычайно эффективный код для обработки рассогласования подобного рода, в которых использовались прерывания и, вообще, не использовались потоки. Повсюду передавались указатели на буферы, а не копировались данные. Вы можете применять этот подход только в том случае, если библиотеки, которыми вы пользуетесь, разработаны так, что позволяют работать с абстракцией буфера, обладающей большими возможностями. Воспользовавшись существующими интерфейсами, мы вынуждены пользоваться потоками и вынуждены копировать данные.

2.6. Блочные файлы BlockFile

Одна из проблем, с которыми сталкивается Audacity, это поддержка операций вставки и удаления в аудиозаписях, длина которых может составлять часы. Записи могут легко стать настолько длинными, что не будут помещаться в доступную оперативную память. Если аудиозапись находится в одном файле на диске, то вставка аудиофрагмента где-нибудь ближе к его началу может означать, что для того, чтобы освободить место для вставляемого фрагмента, потребуется перемещение большого количества данных. На копирование этих данных на диск потребуется много времени, что означает, что из-за этого Audacity не сможет быстро реагировать на простые изменения.

Решение этой проблемы в Audacity состоит в разделении аудиофайлов на большое количество блочных файлов BlockFile, размер каждого из которых может быть равным около 1 МБ. Это основная причина, почему в Audacity есть свой собственный формат звуковых файлов с мастер-файлом с расширением .aup. Это файл XML, с помощью которого координируется использование различные блоки. Изменения вблизи к началу длинной аудиозаписи повлияют только на один блок и на мастер-файл .aup.

В блочных файлах BlockFile соблюден баланс решения двух противоречащих друг-другу задач. Мы можем вставлять и удалять аудиозаписи без чрезмерного копирования, и во время воспроизведения мы при каждом запросе к диску гарантированно получаем достаточно большие куски аудиозаписей. Чем меньше размер блоков, тем потенциально больше требуется запросов к диску для выборки одного и того же количества звуковых данных; чем больше блоки, тем больше объем копирования при вставках и удалениях.

Внутри блочных файлов Audacity никогда нет неиспользуемого внутреннего пространства и они никогда не выходят за пределы максимального размера блока. Чтобы так было всегда, мы, когда вставляем или удаляем фрагменты записи, выполняем копирование по частям, равным размеру одного блока. Когда нам не нужен какой-нибудь блочный файл, то мы его удаляем. Ссылки на блочные файлы сохраняются, так что когда мы удаляем некоторый аудиофрагмент, информация об этом фрагменте будет присутствовать в блочных файлах, тем самым позволяя выполнять операции undo до тех, пока мы не сохраним результат. В блочных файлах Audacity никогда не нужен сборщик мусора для освобождения пространства, который нам бы потребовалось использовать в случае, если бы использовали подход «все в одном файле».

Объединение и разбиение больших кусков данных является основными действиями системы управления данными, начиная от B-деревьев в таблицах BigTable компании Google и заканчивая управлением развернутыми связанными списками. На рис.2.5 показано, что происходит в Audacity при удалении фрагмента аудиозаписи, который находится ближе к началу записи.

Рис.2.5: Перед удалением в файле .aup и в блок-файлах BlockFile хранится последовательность ABCDEFGHIJKLMNO. После удаления фрагмента FGHI два блок-файла BlockFile будут объединены в один

Блочные файлы используются не только для аудиозаписей. Есть также блочные файлы, в которых кэшируется обобщающая информация. Если в Audacity делается запрос отображать на экране запись длинною в четыре часа, то невозможно повторно обрабатывать всю аудиозапись каждый раз, когда происходит перерисовка экрана. Вместо этого используется обобщающая информация, в которой приведены максимальная и минимальная амплитуды аудиосигнала для всех фрагментов записи. Когда масштаб увеличивается, Audacity делает перерисовку с использованием фактических фрагментов записей. Когда масштаб уменьшается, Audacity делает перерисовку с использованием обобщающей информации.

Отличительная особенность системы блочных файлов BlockFile состоит в том, что блоки не обязательно должны быть файлами, созданными в Audacity. Они могут быть ссылками на подразделы аудио файлов, таких как временные промежутки аудиозаписей, хранящихся в формате .wav. Пользователь может создать проект Audacity, импортировать аудиозаписи из файла .wav и смикшировать их с несколькими, создав при этом только блочные файлы с обобщающей информацией. При этом сохраняется дисковое пространство и экономится время копирования аудиозаписей. Однако, все говорят, что это довольно плохое решение. Слишком многие из наших пользователей удаляют исходный аудиофайл .wav, думая, что полная его копия будет находится в папке проекта Audacity. Это не так, поэтому без оригинального файла .wav аудио проект больше воспроизвести не удастся. В настоящее время Audacity по умолчанию всегда копирует импортированную аудиозапись, создавая при этом новые блочные файлы BlockFile.

Решение с использованием блочных файлов вылилось в проблемы в системах Windows, где из-за большого количества блочных файлов сильно падала производительность. Это было связано с тем, что Windows, как оказалось, намного медленнее обрабатывала файлы, когда их много в одном и том же каталоге, что аналогично проблеме с замедлением работы при большом количестве виджетов. В более поздних вариантах было сделано так, что использовалась иерархия подкаталогов и в каждом каталоге никогда не было более сотни файлов.

Основная проблема со структурой блочных файлов связана с тем, что она открыта для конечных пользователей. Мы часто слышим от пользователей, что они переместили файл .aup и не поняли, что также должны были переместить папку, содержащую все блочные файлы. Было бы лучше, если проект Audacity представлял собой один файл, а Audacity взяла на себя ответственность за то, как использовать пространство внутри файла. Во всяком случае это привели бы к увеличению, а не к уменьшению производительности. Основное, что для этого нужно, это - сборщик мусора. Более простой подход состоит в копировании блоков в новый файл при его сохранении в том случае, если в файле не используется более чем некоторый заранее определенный процент памяти.

2.7. Использование сценариев

В Audacity есть экспериментальный плагин, поддерживающий несколько языков сценариев. Он предоставляет интерфейс для сценариев, реализованный поверх конвейера, имеющего имя. Команды сценариев вводятся в текстовом формате, в том же формате выдаются ответы. С помощью языка сценариев можно писать текст и читать текст из именованного конвейера и, таким образом, управлять Audacity. В конвейер не нужно направлять аудиозаписи и другие большие объемы данных (рис. 2.6).

Рис.2.6: Плагин сценариев позволяет писать сценарии, которые перенаправляются в конвейер, имеющий имя.

Самому плагину ничего не известно о содержании текстового трафика, который он передает. Он отвечает только за его передачу. Интерфейс плагинов (или рудиментарная точка расширения), используемый в плагине сценариев и предназначенный для подключения к Audacity, просто в текстовом формате передает команды Audacity. Поэтому плагин сценариев небольшой, а основное содержится в коде конвейера.

К сожалению из-за конвейера возникает проблема с безопасностью, аналогичная той, которая есть в соединениях TCP/IP, и мы по соображениям безопасности исключили из Audacity соединения TCP/IP. Чтобы уменьшить этот риск, плагин был сделан в виде дополнительной DLL. Вы должны принять сознательное решение, чтобы получить и использовать его, и он поставляется с соответствующим предупреждением, касающимся безопасности.

После того, как функция сценария уже была запущена, то согласно предложению, появившемуся на странице запросов нашей wiki-документации, нам бы следовало рассмотреть возможность использования стандарта D-Bus, имеющегося в KDE, для того, чтобы предоставить механизм межпроцессных вызовов с использованием протокола TCP/IP. Мы уже начали идти по другому пути, но, возможно, по-прежнему есть смысл адаптировать интерфейс, который мы сделали, к D-Bus.

Истоки возникновения кода сценария

Возможность использования скриптов возникла из попытки одного энтузиаста адаптировать Audacity для решения особой задачи, что вело к выделению в разработке отдельной ветки. Эти особенности, вместе называемые как CleanSpeech, были предназначены для преобразования проповедей в формат mp3. В CleanSpeech были добавлены новые эффекты, например, усечение пауз — в аудиозаписи находятся и вырезаются длительные паузы, а также применяется определенная последовательность эффектов по удалению существующих шумов, нормализации и преобразования в mp3 целой группы аудиозаписей. Для этого нам потребовались специальные функциональные возможности, но то, как они были написаны, было слишком специальным случаем в Audacity. Перенос их в основную часть Audacity потребовало бы от нас закодировать использование меняющихся, а не фиксированных последовательностей. Меняющаяся последовательность позволила бы использовать любой из эффектов, указав его с помощью справочной таблицы имен команд, а также класса Shuttle, который сохранял бы параметры команды в текстовом формате в пользовательских настройках. Такая функция называется цепочкой команд потоковой обработки (batch chains). Мы совершенно сознательно воздержались от добавления условий и вычислений с тем, чтобы не изобретать специальный язык сценариев.

В ретроспективе, усилия, затраченные на то, чтобы избежать выделения отдельной ветки, оказались правильными. Режим CleanSpeech все еще есть в Audacity, который можно включить при помощи изменения пользовательских настроек. При этом также упрощается пользовательский интерфейс и убираются расширенные возможности. Упрощенная версия Audacity потребовалась для других целей, в первую очередь, в школах. Проблема в том, что каждый по-своему считает, какие возможности являются расширенными, а какие - незаменимыми. Мы впоследствии реализовали простую хитрость, представляющую собой специальный вариант отображения. Когда выполняется отображение пункта меню, который начинается с "#", то этот пункт не показывается в меню. Таким образом, те, кто хотят уменьшить меню, могут сделать этот выбор самостоятельно и без перекомпиляции - более общим и менее инвазивным способом, чем использование в Audacity флага mCleanspeech, который мы со временем сможем полностью удалить.

Работа с CleanSpeech дала нам цепочки команд потоковой обработки и возможность сокращать паузы. Обе возможности внесли дополнительные улучшения. Цепочки команд потоковой обработки непосредственно привели к возможности использования сценариев. Что, в свою очередь, запустило процесс адаптации в Audacity поддержки плагинов общего назначения.

2.8. Эффекты режима реального времени

В Audacity нет эффектов режима реального времени, то есть звуковых эффектов, которые по требованию рассчитываются тогда, когда аудиозапись воспроизводится. Вместо этого в Audacity применяется эффект и нужно ждать его завершения. Одними из наиболее частых требований к Audacity остается добавление эффектов режима реального времени и воспроизведение аудиоэффектов, которые должны происходить в фоновом режиме, а реакция пользовательского интерфейса должна оставаться быстрой.

Проблема, с которой мы имеем дело, состоит в том, что то, что может быть эффектом реального времени на одной машине, может не работать как режим реального времени на другой более медленной машине. Audacity работает на разнообразных машинах. Нам больше подходит, если функциональные возможности снижаются постепенно. На медленных машинах нам бы все еще хотелось иметь возможность запросить эффект, который был быт применен ко всей дорожке, а затем прослушать обработанную аудиозапись где-нибудь ближе к середине дорожки после некоторого ожидания, которое потребуется Audacity с тем, чтобы разбраться, какая часть должна проигрываться первой. На машине, которая слишком медленная для воспроизведения эффекта в режиме реального времени, нам бы нужно было прослушивать аудиозапись до тех пор, пока мы не доберемся до фрагмента, который отображен на экране. Чтобы это сделать, нам бы пришлось разрешить, чтобы аудиоэффекты тормозили пользовательский интерфейс и чтобы порядок обработки аудио блоков был бы только строго слева направо.

Относительно недавнее дополнение в Audacity, которое называется загрузкой по требованию (on demand loading), хотя оно и не связано с аудиоэффектами, обладает многими элементами, которые нам требуются для эффектов режима реального времени. При импорте аудиофайла в Audacity, теперь можно в фоновом режиме создавать блочные файлы с обобщающей информацией. Пока аудиозапись продолжает загружаться, Audacity будет отображать те места аудиозаписи, которые еще не обработаны, как закрашенные серо-голубыми диагональными полосками, и будет продолжать отвечать на множество пользовательских команд. Блоки не обязательно должны обрабатываться последовательно слева направо. Замысел в том, этот же самый код может пригодиться для эффектов режима реального времени.

Загрузка по требованию предлагает нам эволюционный подход к добавлению эффектов режима реального времени. Это шаг, который позволяет избежать некоторых сложностей при создании самих эффектов режима реального времени. Для эффектов режима реального времени также дополнительно потребуется взаимное наложение между блоками, поскольку в противном случае такие эффекты, как эхо, не будут правильно подключаться. Мы также должны позволить менять параметры во время проигрывания аудиозаписи. Благодаря тому, что сначала сделана загрузка по требованию, код стал использоваться на более раннем этапе, чем это могло бы быть. Благодаря фактическому использованию кода возникнет обратная связь и появятся уточнения.

2.9. Заключение

В предыдущих разделах данной главы рассказано, как хорошая структура способствует развитию программы или как отсутствие хорошей структуры этому мешает.

Чем больше смотришь, тем очевиднее, что Audacity является результатом коллективной работы. Сообщество — это больше, чем просто все те, кто непосредственно сотрудничает друг с другом, поскольку зависят от библиотек, у каждой из которых есть свое собственное сообщество с собственными экспертами в своих вопросах. Читая о структурной смеси в Audacity, вероятно, не следует удивляться, что когда сообщество развивается, в него привлекаются новые разработчики, причем с совершенно разными уровнями квалификации.

У меня нет никаких сомнений в том, что то, какое сообщество стоит за Audacity, отражено в сильных и слабых сторонах кода. Более закрытые группы могли бы написать более качественный код, чем тот, который есть у нас, и более последовательно, но при меньшем количестве участников было бы гораздо труднее реализовать весь спектр возможностей Audacity.

Примечания

  1. Единственным исключением из этого является язык Найквиста на базе Lisp (Lisp-based Nyquist language), который был встроен в Audacity с самого начала. Мы хотели бы сделать его отдельным модулем, поставляемым в комплекте с Audacity, но нам не хватило времени сделать эти изменения.

3.1. Ведение

Командная оболочка Unix предоставляет интерфейс, который позволяет пользователю при помощи запуска команд взаимодействовать с операционной системой. Но в командной оболочке также представлен довольно богатый язык программирования: есть конструкции управления порядком выполнения команд, изменения этого порядка, организации циклов, условного исполнения; есть также основные математические операции, именованные функции, строковые переменные, а также двунаправленная связь между командной оболочкой и командами, которые в ней вызываются.

Командные оболочки можно использовать в интерактивном режиме, обращаясь к ним из терминала или эмулятора терминала, например, из xterm, или неинтерактивно, читая команды из файла. В большинстве современных командных оболочек, в том числе и в bash, есть средства редактирования командной строки, в которых с помощью команд, похожих на команды emacs или vi, можно манипулировать с командной строкой, пока она еще не введена, а также есть различные варианты сохранения команд в списке уже выполненных команд.

Обработка данных в bash очень похожа на работу конвейера командной оболочки: после того, как данные считываются из терминала или из скрипта, они проходят через ряд стадий, трансформируясь на каждой из них, до тех пор, пока командная оболочка наконец не выполнит команду и не получит код состояния, возвращаемый командой.

В данной главе мы рассмотрим основные компоненты bash - обработку входных данных, синтаксический анализ, различные раскрытия слов и прочую обработку команд, а также исполнение команд, если их рассматривать с точки зрения конвейерной обработки. Эти компоненты выступают в роли конвейера, который считывает данные с клавиатуры или из файла, превращая их в выполненные команды.

Компонентная архитектура bash

Рис. 3.1: Компонентная архитектура bash

3.1.1. Bash

Bash является командной оболочкой, которая появилась в операционной системе GNU, обычно реализуемой поверх ядра Linux, а также в некоторых других операционных системах и, в первую очередь, в Mac OS X. По сравнению с ушедшими в историю версиями оболочки sh, в bash улучшены функциональные возможности, касающиеся как интерактивного режима, так и возможностей программирования.

Название является сокращением от Bourne-Again SHell, каламбура объединения имени Стивена Борна (Stephen Bourne - автор прямого предшественника используемой в текущих версиях Unix командной оболочки /bin/sh, который появился в версии Bell Labs Seventh Edition Research системы Unix) с понятием перерождения через новую реализацию. Подлинным автором командной оболочки bash был Брайан Фокс (Brian Fox), сотрудник фонда Free Software Foundation. Я работаю в качестве волонтера в университете Case Western Reserve University в Кливленде, штат Огайо, и в настоящее время являюсь разработчиком bash, а также осуществляю его поддержку.

Точно также как и другое программное обеспечение GNU, bash является полностью переносимым. В настоящее время он работает практически на любой версии Unix, а также на нескольких других операционных системах — есть независимые порты bash , например, для сред Cygwin и MinGW, поддерживаемых в Windows, также порты bash входят в состав дистрибутивов для Unix-подобных операционных систем, например, QNX и Minix. Чтобы собрать и запустить bash, необходима только среда Posix, например, такая, что предоставлена фирмой Microsoft в составе сервисов Services for Unix (SFU).

3.2. Синтаксические единицы и примитивы

3.2.1. Примитивы

Что касается bash, то в ней есть три основных вида лексем: зарезервированные слова, слова и операторы. Зарезервированными словами являются такие, которые в командной оболочке и в ее языке программирования имеют особое значение; как правило, эти слова применяются для написания конструкций, определяющих последовательность выполнения действий, например, if и while. Операторы состоят из одного или нескольких метасимволов - символов, которые, сами по себе, имеют в командной оболочке особое значение, например, | и >. Остальные данные, вводимые в командной оболочке, представляют собой обычные слова, некоторые из которых в зависимости от того, где в командной строке они находятся, имеют особое значение — например, инструкции присваивания или числа.

3.2.2. Переменные и параметры

Как и в любом языке программирования, в командной оболочке есть переменные: это имена, на которые ссылаются при хранении данных и при выполнении над ними операций. В командной оболочке можно пользоваться обычными переменными, которые могут задавать пользователи, и несколькими встроенными переменными, которые называются параметрами. В командной оболочке параметры, как правило, отражают некоторые аспекты внутреннего состояния командной оболочки, и устанавливаются автоматически, или в результате побочного эффекта при выполнении другой операции.

Значениями переменных являются строки. Некоторые значения, в зависимости от контекста, трактуются специальным образом; об этом будет рассказано позже. Переменные назначаются с помощью инструкций вида name=value. Значение value является необязательным; если оно не указано, то переменной по имени name присваивается пустая строка. Если значение указано, оболочка раскрывает значение и присваивает его переменной по имени name. В зависимости от того, задано ли значение переменной или нет, оболочка может выполнять разные операции, однако единственным способом задать переменной значение является присваивание. Переменные даже в случае, если они были объявлены и для них были заданы атрибуты, но которым значение еще не присвоено, считаются неопределенными (имеют значение unset).

Слово, начинающееся с символа доллара, является переменной или ссылкой на параметр. Слово, в том числе и символ доллара, заменяется на значение переменной с эти именем. В командной оболочке имеется богатый набор операторов, предназначенный для раскрытия значений, начиная от простой замены значения и до изменения или удаления частей значения переменной, соответствующих некоторому образцу.

Есть положения о локальных и глобальных переменных. По умолчанию, все переменные являются глобальными. Любая простая команда (наиболее знакомый тип команды - название команды и необязательные набор аргументов и ссылок) может предваряться набором операторов присваивания, в этом случае переменные существуют только для этой команды. В командной оболочке есть реализации хранимых процедур или функций командной оборочки, в которых могут быть локальные переменные этих функций.

Типизация переменных минимальна: в дополнение к обычным строковым переменным есть целые числа (тип integer) и массивы (тип integer). Переменные целочисленного типа трактуются как числа: любая присваиваемая им строка раскрывается как арифметическое выражение и полученный результат присваивается переменной в качестве значения. Массивы могут быть индексные или ассоциативные; в индексных массивах в качестве индексов используются числа, а в ассоциативных массивах - произвольные строки. Элементы массива являются строками, которые, если это необходимо, можно рассматривать как целые числа. Элементами массива не могут быть другие массивы.

В bash для запоминания переменных и доступа к ним используются хэш-таблицы, а для реализации областей видимости переменных - связные списки, состоящие из таких хэш-таблиц. Есть различные области видимости переменных для вызовов функций командной оболочки и временные области видимости для переменных, значения которых устанавливается в инструкциях присваивания, предшествующих команде. Когда, например, эти инструкции присваивания предшествуют команде, встроенной в командную оболочку, оболочка должна следить за тем порядком, в котором следует обращаться по ссылкам на эти переменные, и связные области видимости позволяют bash это делать. В зависимости от того, какой уровень вложенности используется, может потребоваться просмотреть на удивление много областей видимости.

3.2.3. Язык программирования командной оболочки

Простая команда командной оболочки, которая более всего известна большинству читателей, состоит из имени команды, например, echo или cd, и списка, состоящего из нуля или большего числа аргументов и перенаправлений. Перенаправления позволяют пользователю командной оболочки управлять в вызываемых командах вводом и выводом данных. Как отмечалось выше, пользователи могут для простых команд определять локальные переменные.

С помощью зарезервированных слов вводятся более сложные команды командной оболочки. Есть конструкции, обычные для любого высокоуровневого языка программирования, например, if-then-else, while, цикл for для итерации по списку значений, а также арифметический цикл for, похожий на используемый в языке C. С помощью этих более сложных команд командная оболочка может выполнить команду или другую проверку условия и, в зависимости от полученного результата, выполнять различные операции, либо может повторять выполнение команды много раз.

Одним из подарков, который Unix принес в компьютерный мир, является конвейер: линейный список команд, в котором выход одной команды в списке становится входом следующей команды. В конвейере можно использовать любую конструкцию командной оболочки; не редкость видеть конвейеры, в которых в цикле обрабатываются данные, выдаваемые командами.

В bash реализован механизм, который позволяет, когда вызывается команда, перенаправлять стандартный входной поток, стандартный выходной поток и стандартный поток ошибок в другой файл или процесс. Программисты, пользующиеся командной оболочкой, также могут в среде текущей оболочки осуществлять перенаправление в открытые и закрытые файлы.

Bash позволяет запоминать программы, работающие в командной оболочке, и использовать их более одного раза. Есть два способа именования групп команд - создать функцию командной оболочки или создать скрипт командной оболочки — и выполнить их точно также, как и любую другую команду. Функции командной оболочки объявляются с использованием специальных синтаксических правил, а затем запоминаются и выполняются в контексте той же самой командной оболочки; скрипт командной оболочки создается при помощи записи команд в файл и выполнение этого файла представляет собой запуск нового экземпляра командной оболочки, которая интерпретирует этот файл. Функции обычно выполняются в командной оболочке в том же самом контексте исполнения, из которой они были вызваны; однако скрипты, поскольку они интерпретируются в новом экземпляре командной оболочки, могут совместно использовать только те данные, которые в среде командных оболочек могут передаваться между процессами.

3.2.4. Дополнительное замечание

Когда вы будете читать дальше, имейте в виду, что все возможности командной оболочка реализованы с использованием всего лишь нескольких структур данных: массивов, деревьев, односвязных и двусвязных списков и хэш-таблиц. Почти все конструкции, имеющиеся в командной оболочке, реализованы с использованием этих примитивов.

Основной структурой данных, используемой командной оболочкой для передачи информации от одного этапа к другому, а также для работы с элементами данных на каждом этапе обработки, является структура WORD_DESC:

typedef struct word_desc {
  char *word;           /* Zero terminated string. */
  int flags;            /* Flags associated with this word. */
} WORD_DESC;

Слова, например, объединяются в списки аргументов с помощью простых связанных списков:

typedef struct word_list {
  struct word_list *next;
  WORD_DESC *word;
} WORD_LIST;

Списки слов вида WORD_LIST используются в командной оболочке повсюду. Простая команда является списком слов, результат раскрытия выражения является списком слов, а в каждой встроенной команде список слов используется в качестве аргументов.

3.3. Обработка входных данных

Первой стадией конвейерной обработки в bash является обработка входных данных: из терминала или из файла берутся символы, из них формируются строки, а затем строки передаются в синтаксический анализатор командной оболочки для их преобразования в команды. Как вы и ожидали, строки представляют собой последовательности символов, заканчивающиеся символами новой строки.

3.3.1. Readline и редактирование командной строки

Bash, когда он находится в интерактивном режиме, читает входные данные с терминала, либо, в противном случае, из файла-скрипта, указываемого в качестве аргумента. В интерактивном режиме bash позволяет пользователю с помощью известных последовательностей нажатий клавиш и команд редактирования, похожих на те, что есть в системе Unix в редакторах emacs и vi, редактировать командные строки, которые он набрал.

В bash для редактирования командных строк используется библиотека readline. В ней есть функции, позволяющие пользователям редактировать строки команд, сохраняющие строки команд по мере их ввода, повторно обращающиеся к ранее набранным командам, а также раскрывающие команды по списку истории команд наподобие того, как это сделано в csh. Bash является основным клиентским приложением readline и они разрабатываются вместе, но в readline нет кода, зависящего от bash. Библиотека readline используется во многих других проектах для реализации интерфейса редактирования строк, вводимых с терминала.

Readline также позволяет пользователям связывать последовательности нажатий клавиш неограниченной длины с любой из команд из большого количества команд, имеющихся в readline. В readline есть команды для перемещения курсора вдоль строки, вставки и удаления текста, поиска предыдущих строк и автозавершения частично набранных слов. Помимо этого, пользователи могут определять макросы, которые являются строками символов, вставляемых в командную строку в ответ на нажатие последовательности клавиш; в макросах используется тот же самый синтаксис, что и при связывании последовательностей клавиш с командами. Макросы предоставляют пользователям библиотеки readline средства простой подстановки строк и сокращения усилий при вводе данных.

Структура readline

С точки зрения структуры readline представляет собой цикл "чтения / диспетчеризации / исполнения / повторного отображения". Библиотека читает символы с клавиатуры с помощью операции read или другой эквивалентной, либо получает их из макроса. Каждый символ рассматривается как индекс в таблице раскладки клавиатуры или таблицы диспетчеризации. Хотя в качестве индексов используются одиночные восьмибитовые символы, содержимое каждого элемента таблицы раскладки может использоваться для различных целей. Символы могут использоваться для доступа к дополнительным таблицам клавиатурных раскладок, в которых могут быть заданы многосимвольные последовательности нажатий клавиш. Если выясняется, что символ является некоторой командой readline, например, командой beginning-of-line (начало строки), то будет выполнена эта конкретная команда. Символ, связанный с командой self-insert (самоподставляемый), запоминается в буфере редактирования. Также можно связать последовательность нажатия клавиш с некоторой командой, причем в качестве этой команды использовать последовательность нескольких объединенных вместе различных команд (возможность, добавленная сравнительно недавно); в таблице раскладки есть специальный индекс, указывающий, что была сделана такая привязка. Привязка последовательности нажатия клавиш к макросам позволяет достичь еще большой гибкости: от вставки в командную строку произвольных строк и до создания горячих клавиш для сложных последовательностей операций редактирования. Библиотека readline запоминает каждый символ, связанный с командой self-insert (самоподставляемый), в буфере редактирования, который, когда он отображается, может занимать на экране одну или несколько строк.

В библиотеке readline используются символьные буфера и строки, содержащие только тип данных chars языка C и, если необходимо, из них создаются многобайтовые символы. Тип данных wchar_t внутри библиотеки не используется по причинам, связанным со скоростью работы и способами хранения данных, а также из-за того, что код, с помощью которого выполняется редактирование, был создан раньше, чем поддержка многобайтовых символов получила широкое распространение. Когда в локали поддерживается использование многобайтовых символов, то readline автоматически считывает целиком весь многобайтовый символ и помещает его в буфер редактирования. Можно связать многобайтовые символы с командами редактирования, но нужно связывать такой символ как последовательность нажатия клавиш; это возможно, но сложно и, как правило, не требуется. Например, в существующих наборах команд emacs и vi многобайтовые символы не используются.

Как только в команде редактирования будет окончательно определена последовательность нажатий клавиш, readline обновит изображение, выдаваемое на дисплей, для того, чтобы отобразить эти результаты. Это происходит независимо от того, будут ли результаты работы команды в виде символов вставлены в буфер, измениться ли позиция, в которой выполняется редактирование, или будет ли частично или полностью заменена строка. Некоторые связываемые команды редактирования, например, те, что изменяют файл истории команд, не будут вызывать никаких изменений в содержимом буфере редактирования.

Хотя процесс обновления изображения, выдаваемого на терминал, и кажется простым, он состоит из ряда действий. Библиотека readline должна следить за тремя вещами: за текущим содержимым буфера символов, отображаемых на экране, за обновлениями содержимого этого буфера изображений и за фактически отображаемыми символами. Когда имеются многобайтовые символы, отображаемые символы не соответствуют точно содержимому буфера и средства обновления изображения должны это учитывать. Когда происходит обновление изображения, readline должна сравнить содержимое буфера текущего изображения с обновленным буфером, выявить различия, и решить, как с учетом обновлений, имеющихся в буфере, наиболее эффективно обновить изображение. Эта проблема была предметом серьезного исследования на протяжении многих лет (проблема корректировки вида "строка в строку"). Подход, используемый в readline, состоит в выявлении начала и конца той части буфера, которая отличается, вычисления затрат на обновление только этой части, в том числе на перемещение курсора назад или вперед (например, потребуется ли больше затрат для того, чтобы выдать на терминал команды, которые удалят символы, а затем вставят новые, вместо простой перезаписи текущего содержимого экрана?), выполнении самого меньшего по затратам варианта обновления, а затем, если это необходимо, очистки — удаления всех символов, оставшихся в конце строки, и установке курсора в нужном месте.

Механизм обновления изображения является, без сомнения, одной из частей readline, которые подверглись наибольшим переделкам. Много изменений было сделано для расширения функциональных возможностей — наиболее значимой является возможность использовать в запросе на ввод команды неотображаемые символы (которые, например, изменяют цвет символов), а также обрабатывать символы, занимающие более одного байта.

Readline возвращает содержимое буфера редактирования в приложение, из которого оно было вызвано и которое затем должно сохранить результаты, возможно измененные, в списке истории команд.

Расширение функциональных возможностей readline в приложениях

Точно также как пользователям в readline предлагаются различные способы настройки и расширения функциональных возможностей, используемых по умолчанию, так и приложениям предоставляется ряд механизмов, позволяющих расширить набор возможностей, предлагаемых по умолчанию. Во-первых, подключаемые функции readline получают доступ к стандартному набору аргументов и возвращают определенный набор результатов, что позволяет приложениям легко расширять возможности readline своими собственными функциями. Например, в bash добавлено более тридцати команд, используемых при связывании: от автозавершения конкретных слов и до интерфейсов к командам, встроенным в командную оболочку.

Во втором случае readline позволяет изменить свое поведение за счет использования повсеместно имеющихся указателей на функции, осуществляющие перехват управления (hook function), имена и интерфейс вызова которых хорошо известны. Приложениям разрешено подменять некоторые внутренние фрагменты кода readline, вставлять перед работой readline некоторые собственные функции и выполнять преобразования, необходимые конкретному приложению.

3.3.2. Обработка входных данных в неинтерактивном режиме

Когда командная оболочка не пользуется библиотекой readline, она для получения входных данных будет использовать либо stdio, либо свои собственные подпрограммы буферированного ввода. Если командная оболочка находится в неитерактивном режиме, то использование пакета буферированного ввода, который есть в bash, более предпочтительно, чем stdio, из-за нескольких своеобразных ограничений, которые связаны в Posix с тем, что следует делать при вводе данных: на вход командной оболочки долны поступать только данные, необходимые для анализа команд, а все остальное должно передаваться исполняемым программам. В частности это важно, когда оболочка считывает скрипт из стандартного входного потока. Командная оболочка может буферировать входные данные в том объеме, сколько это будет необходимо, и так долго, пока в файле сразу после того, как будет проанализирован последний символ, не будет выполнен откат обратно. С практической точки зрения это значит, что когда данные считываются из устройств, в которых нет возможности выполнять поиск, например, из конвейеров, командная оболочка должна считывать скрипт символ за символом, но когда чтение происходит из файла, в буфер можно записывать столько символов, сколько будет необходимо.

Если оставить эти особенности в стороне, то на выходе процесса обработки, выполняемого командной строкой при неитерактивном вводе входных данных, будет то же самое, что и у readline: буфер символов, заканчивающихся символом новой строки.

3.3.3. Многобайтовые символы

Обработка многобайтовых символов была добавлена в командную оболочку значительно позже первоначальной реализации оболочки, причем это было сделано таким образом, чтобы свести к минимуму влияние этого добавления на уже существующий код. Когда установлена локаль, в которой есть поддержка многобайтовых символов, то командная оболочка записывает свои входные данные в буфер, состоящий из байтов (тип char языка С), но трактует эти байты как потенциально возможные многобайтные символы. В readline известно, как выводить многобайтовые символы (здесь важно знать, сколько позиций на экране занимает многобайтный символ и сколько байтов нужно считать из буфера, когда символ отображается на экране), как перемещаться по строке вперед и назад в случае, когда перемещение происходит не на один байт за один раз, и так далее. Во всем другом многобайтовые символы не оказывают большого влияния на процесс ввода данных. Другие части командной оболочки, которые будут описаны ниже, должны знать о том, что используются многобайтовые символы, и учитывать это при обработке входных данных.

3.4. Анализ

Первой частью работы, выполняемой при анализе данных, является лексический анализ: поток символов в соответствие со смыслом слов разделяется на слова. Слово является основной единицей, с которой работает синтаксический анализатор. Слова являются последовательностями символов, разделенных метасимволами, которыми могут быть простые разделители, например, пробелы и символы табуляции, или символы, являющиеся специальными в языке командной оболочки, например, символ точки с запятой и символ амперсанда.

Одна исторически сложившаяся проблема, касающаяся командной оболочки, состоит в том, как сказал Том Дафф (Tom Duff ) в своей статье о rc - командной оболочке системы Plan 9, что никто не знает, что такое грамматика оболочки Борна. Особой благодарности заслуживает Комитет по стандарту оболочки Posix, который, наконец, опубликовал окончательную редакцию грамматики для оболочки Unix, хотя и в ней есть масса контекстных зависимостей. Эта грамматика не без проблем — в ней запрещены некоторые конструкции, которые были бы без ошибок восприняты давно созданными синтаксическими анализаторами оболочки Борна, но это лучшее, что у нас есть.

Синтаксический анализатор bash был создан на основе ранней версии грамматики Posix, и, насколько я знаю, является лишь синтаксическим анализатором командной оболочки в стиле Борна, реализованной с помощью Yacc или Bison. Вследствие этого возник определенный набор трудностей — в действительности грамматика командной оболочки не очень хорошо подходит для синтаксического анализа в стиле yacc и требует более сложного лексического анализа и большего объема взаимодействий между синтаксическим и лексическим анализаторами.

В любом случае, лексический анализатор получает входные строки из readline или другого источника, разбивает их на лексемы, разделяемыми метасимволами, идентифицирует лексемы с учетом контекста и передает их в синтаксический анализатор для сборки их в инструкции и команды. Контекст может быть весьма различным — например, слово for может быть зарезервированным словом, идентификатором, часть инструкции присваивания, или другим словом, и следующая команда, которая является вполне допустимой:

for for in for; do for=for; done; echo $for

выдает на терминал слово for.

В данный момент настала очередь сделать небольшое отступление, относящееся к использованию алиасов (или синонимов — прим.пер.). Bash позволяет с помощью методики алиасов заменять произвольным текстом первое слово простой команды. Поскольку эта замена полностью лексическая, алиасы можно даже употреблять (или ими злоупотреблять) для того, чтобы изменить грамматику командной оболочки: можно написать алиас, реализующий составную команду, которой нет в bash. Анализатор bash реализует методику алиасов полностью на фазе лексического анализа, тем не менее, синтаксический анализатор должен информировать лексический анализатор, когда расрытие алиасов не допускается.

Как и во многих других языках программирования, в командной оболочке разрешается перед специальными символами указывать другие специальные символы, отменяющие особенности использования первых специальных символов (те. использовать так называемые escape-последовательности символов — прим.пер.), поэтому в командах можно использовать метасимволы, например, &. Есть три типа кавычек, каждый из которых немного отличается и позволяет несколько по иному интерпретировать выделенный текст: обратный слеш, который экранирует следующий символ, одинарные кавычки, которые предотвращают интерпретацию всех символов, находящихся внутри кавычек, и двойные кавычки, которые отключают некоторые виды интерпретации, но позволяют выполнять раскрытие некоторых слов (и иначе интерпретировать символы обратного слеша). Лексический анализатор считывает символы и строки, заключенные в кавычки, и не позволяет синтаксическому анализатору искать в них зарезервированные слова или метасимволы. Есть также два особых варианта - $'…' и $"…", в которых символы, перед которыми указан обратный слеш, раскрываются точно также, как это делается в строках языка ANSI C, и в которых можно, соответственно, транслировать символы с использованием функций, поддерживающих интернационализацию. Первый вариант используется широко, последний, возможно, из-за того, что для него приведено мало хороших примеров или вариантов его использования, применяется в меньшей степени.

Остальная часть интерфейса между синтаксическим и лексическим анализаторами сравнительно проста. Синтаксический анализатор кодирует определенное количество состояний и использует их совместно с лексическим анализатором для того, чтобы можно было с помощью грамматики выполнить контекстно-зависимый анализ. Например, лексический анализатор классифицирует слова в зависимости от типа лексемы: зарезервированное слово (в соответствующем контексте), слово, инструкция присваивания и так далее. Чтобы это сделать, синтаксический анализатор должен независимо от того, обрабатывается ли многострочный текст (иногда называемых "встроенными документами"), анализируется ли инструкция case или команда условного выполнения, обрабатывается ли расширенный шаблон или составная инструкция присваивания, сообщить некоторые сведения о том, насколько далеко вперед продвинулся синтаксический разбор команды.

Большая часть работы, связанной с нахождением конца замещения команды на стадии синтаксического анализа, инкапсулирована в одной функции (parse_comsub), которая разбирается со всеми неудобными случаями синтаксиса командной оболочки и в ней гораздо больше кода, читающего лексемы, чем это было бы в оптимальном случае. Эта функция должна знать о встроенных документах, о комментариях командной оболочки, о метасимволах и границах слов, об использовании кавычек и случаях, когда можно использовать зарезервированные слова (так что она знает, когда они должны быть в инструкции case); потребовалось время чтобы делать это правильно.

Когда в процессе раскрытия слов необходимо замещение команды, bash для того, чтобы найти правильное окончание конструкции, пользуется синтаксическим анализатором. Это аналогично преобразованию строки в команду с помощью команды eval, но в этом случае команда не завершается концом строки. Чтобы выполнить эту работу, синтаксический анализатор должен распознать правую скобку как признак завершения команды; при выводе ряда грамматических правил это приводит к частным случаям и требуется, чтобы лексический анализатор помечал правую скобку (в соответствующем контексте) как символ конца файла EOF. Прежде, чем рекурсивно обращаться к yyparse, анализатор также должен уметь сохранять и восстанавливать свое внутреннее состояние, поскольку при замещении команды в процессе ее чтения может потребоваться произвести синтаксический анализ и выполнить часть операции раскрытия. Поскольку в функциях ввода данных реализовано упреждающее чтение, то независимо от того, будет ли bash читать данные из строки файла или с терминала с помощью readline, следует, наконец, также позаботиться о перемещении указателя входного потока bash вправо в нужное место. Это важно не только чтобы не потерять входные данные, но также и для того, чтобы функции, выполняющие замещение команд, создали для исполнения правильную строку.

Аналогичные проблемы встают при программировании автозавершения слов, когда при синтаксическом разборе одной команды может выполнять произвольное количество любых других команд, причем во всех вызовах синтаксического анализатора нужно решать проблему сохранения и восстановления его состояния.

Использование кавычек также является источником несовместимости и обсуждений. Спустя двадцать лет после того, как был опубликован первый стандарт командной оболочки Posix, члены Рабочей группы по стандартам до сих пор обсуждают правильную обработку неясных случаев использования кавычек. Как и прежде, командная оболочка Bourne не поможет ничем, кроме как понаблюдать за ней как за эталонной реализацией.

Синтаксический анализатор возвращает единственную структуру на языке C, представляющую собой команду (которая, в случае составных команд, например, циклов, может, в свою очередь, состоять из нескольких других команд), и передает ее на следующий этап работы командной оболочки - этап раскрытия слов. В структуре хранятся объекты, представляющие команду, и списки слов. Большая часть списков слов будет, в зависимости от контекста, преобразовано, что описывается в следующих разделах статьи.

3.5. Раскрытие слов

После синтаксического разбора, но до этапа исполнения, многие из слов, сформированные на стадии синтаксического анализа должны быть подвергнуты одной или нескольким операциям раскрытия, так, например, слово $OSTYPE будет заменено строкой "linux-gnu".

3.5.1. Раскрытие параметров и переменных

Пользователи лучше всего известно раскрытие переменных. Переменные командной оболочки вводятся с клавиатуры и, за немногими исключениями, трактуются как строки. Операция раскрытия заменяет эти строки и трансформирует их в новые слова и списки слов.

Есть варианты операций раскрытия, которые действуют на значение самой переменной. Программисты могут ими пользоваться для получения подстрок из значения переменной, определения длины строки, удаления различных частей строки, соответствующих при просмотре с начала строки или с ее конца заданному шаблону, замены части строки, соответствующей заданному шаблону, новой строкой, или изменения набора символов.

В добавок есть варианты раскрытия, которые зависят от состояния переменной: в зависимости от того, задано ли переменной значение или нет, могут выбираться различные варианты раскрытия или присваивания различные значения. Например, ${parameter:-word} будет раскрыто как parameter в случае, если значение переменной задано, и как word в случае, если оно не задано или задано значение пустой строки.

3.5.2. И многое другое

Bash выполняет много других вариантов операций раскрытия, для каждой из которых есть свои собственные причудливые правила. Сначала обрабатывается раскрытие скобок, которое превращает:

pre{one,two,three}post

в:

preonepost pretwopost prethreepost

Также есть замещение команд, что замечательно сочетается с возможностью командной оболочки запускать команды и манипулировать с переменными. Оболочка запускает команду, сохраняет результат ее работы и использует его в качестве значения, используемого в операции раскрытия.

Одна из проблем, связанная с замещением команд, состоит в том, что в этом случае немедленно запускается команда, работающая изолированно, и ожидается ее завершение: в командной оболочке нет простого способа передать команде входные данные. В bash используется возможность, называющаяся замещением процессов и представляющая собой своего рода комбинацию замещения команды и конвейеров командной оболочки, благодаря чему этот недостаток компенсируется. Точно также, как и в случае замещения команд, в bash запускается команда, но она работает в фоновом режиме и bash не ожидает ее завершения. Главное то, что bash открывает конвейер к команде для чтения или записи и указывает в конвейере имя файла, который станет результатом операции раскрытия.

Далее идет раскрытие символа тильды ~. Первоначально предполагалось, что ~alan будет ссылкой на домашний каталог Алана, но за прошедшие годы этот вариант раскрытия сильно расширился и позволяет ссылаться на большое количество различных каталогов.

Наконец, имеется раскрытие арифметических выражений. В $((expression)) выражение expression должно вычисляться по тем же правилам, что выражения языка C. Результат вычисления выражения становится результатом раскрытия.

Раскрытие переменных это как раз тот случай, когда наиболее очевидной становится разница между одинарными и двойными кавычками. Одинарные кавычки отключают все раскрытия - символы, заключенные в кавычки, передаются через операцию раскрытия без всяких изменений, тогда как в случае двойных кавычек некоторые раскрытия выполняются, а другие — не выполняются. Выполняется раскрытие слов и команд, раскрытие арифметических выражений и выполняется замещение процессов - двойные кавычки только влияют то, как обрабатывается результат, раскрытие скобок и символа тильды не выполняется.

3.5.3. Разбиение на слова

Результат раскрытия слов разбивается на отдельные слова, причем в качестве разделителей слов используются символы, указанные в переменной командной оболочки IFS. Речь идет о том, как командная оболочка преобразует одно слово в несколько слов. Каждый раз, когда в результате выполнения операции раскрытия обнаруживается один из символов, указанных в переменной $IFS (смотрите в конце статьи примечание 1) bash разбивает слово на два новых слова. Одинарные и двойные кавычки отключают функцию разбиения на слова.

3.5.4. Подстановка

После того, как результаты будут разбиты на слова, командная оболочка проинтерпретирует каждое слово, полученное в результате предыдущих раскрытий, в качестве потенциального шаблона и попытается сопоставить его с существующим полным именем файла, включающим все пути, ведущий к каталогам.

3.5.5. Реализация

Если базовая архитектура командной оболочки допускает распараллеливание конвейеров, то раскрытие слов будет небольшим конвейером, замкнутым самим на себе. На каждом этапе раскрытия слов берется некоторое слово и после того, как оно, возможно, будет преобразовано, оно передается на следующий этап раскрытия. После того, как все этапы раскрытия слова будут пройдены, будет выполнена команда.

Реализация раскрытия слов в bash строится на основе уже ранее описанных структур данных. Слова, выдаваемые синтаксическим анализатором, раскрываются по одному, в результате чего каждое отдельное слово, имеющееся на входе, будет раскрыто на выходе в виде одного или нескольких слов. Структура данных WORD_DESC оказалась достаточно универсальной и в ней может храниться вся информация, необходимая для инкапсуляции результатов раскрытия одного слова. Для кодирования информации, используемой на стадии раскрытия слов, а затем передаваемой с этой стадии на следующую, применяются флаги. Например, синтаксический анализатор использует флаг, говорящий на стадиях раскрытия слов и исполнения команд о том, что конкретное слово является инструкцией присваивания командной оболочки; а в коде, осуществляющим раскрытие слов, флаги используются для запрета разбиения на слова или пометки о присутствии заключенной в кавычки строки, имеющей значение null, ("$x", где $x не определено или имеет значение null). Использовать для раскрытия всех слов единую строку символов с какой-то кодировкой для представления дополнительной информации, оказалась бы гораздо сложнее.

Как и в синтаксическом анализаторе, в коде, осуществляющем раскрытие слов, обрабатываются символы, для представления которых требуется более одного байта. Например, длина переменной при раскрытии (${#variable}) подсчитывается в символах, а не в байтах, и код в случае использования многобайтовых символов может правильно идентифицировать завершение операций раскрытия и найти специальные символы, используемые при раскрытии.

3.6. Исполнение команд

Стадия выполнения команд внутреннего конвейера bash является тем местом, где происходит реальное действие. Как правило, набор слов, для которых было выполнение раскрытие декомпозируется на имя команды и набор аргументов и передается в операционную систему в виде файла, который читается и исполняется вместе с остальными словами, передаваемыми в остальных элементах структуры argv.

В нашем описании внимание до сих пор умышленно сосредотачивалось на том, что Posix обращается к простым командам — тем, у которых есть имя и набор аргументов. Это наиболее распространенный тип команд, но у bash намного больше возможностей.

На входе на стадию выполнения команд используется структура данных, созданная синтаксическим анализатором и заполненная возможно раскрытыми словами. Вот где в игру вступает настоящий язык программирования bash. В языке программирования, как уже указывалось ранее, используются переменные и раскрытия и также реализованы конструкции, наличие которых следовало бы ожидать в языке высокого уровня: циклы, условные инструкции, чередование, объединение в множества, выбор из множеств, условное выполнение с учетом сопоставления с шаблоном, оценка выражений и несколько конструкций более высокого уровня, характерных для командной оболочки.

3.6.1. Перенаправление

Одна из особенностей роли командной оболочки, когда она используется как интерфейс к операционной системе, это делать перенаправление ввода и вывода в команде, которая вызывается. Синтаксис перенаправления является одной из тех составляющих, которые представили первым пользователям всю сложность командной оболочки: до недавнего времени требовалось, чтобы пользователи следили за дескрипторами файлов, которыми они пользуются, и в случае, когда дескриптор отличался от стандартных потоков ввода, вывода и ошибок, явно указывали его номер.

Недавнее дополнение к синтаксису перенаправления позволяет пользователям вместо того, чтобы давать пользователю самостоятельно выбирать дескриптор файла, дать указание командной оболочке выбрать подходящий дескриптор файла и присвоить его конкретной переменной. Программистам становится проще отслеживать дескрипторы файлов, но это требует дополнительной обработки: оболочка должна сделать в подходящем месте копии дескрипторов файлов и проверять, что они назначены конкретной переменной. Это еще один пример того, как информация передается из лексического анализатора через синтаксический анализатор на стадию выполнения команды: лексический анализатор определяет, что в слове, в котором есть перенаправление, осуществляется присваивание значения переменной; синтаксический анализатор при помощи соответствующих правил грамматического вывода создает объект, используемый при перенаправлении, у которого есть флаг, указывающий, что требуется аргумент; код, осуществляющий перенаправление, получает этот флаг и обеспечивает, чтобы номер дескриптора файла был назначен правильной переменной.

Самой трудной частью реализации перенаправления является запоминание информации, необходимой для отмены перенаправления. В командной оболочке намеренно стирается различие между командами, выполняемыми из файловой системы, что требует создания нового процесса, и командами, которые оболочка выполняет сама (встроенные команды), но, вне зависимо от того, как команда реализована, эффект перенаправления не должен сохраняться после того, как эта команда будет завершена (смотрите в конце статьи примечание 2). Поэтому командная оболочка должна следить за тем, как отменить эффект каждого перенаправления, в противном случае перенаправление выходного потока во внутренней команде изменит стандартный выходной поток самой командной оболочки. В bash известно, как отменять перенаправление каждого типа: либо с помощью закрытия дескриптора файла, который ранее был выделен, либо с помощью создания дубля дескриптора файла и позже восстановления дескриптора с помощью команды dup2. Здесь используются те же самые объекты перенаправления, которые были созданы синтаксическим анализатором, а для обработки используются те же самые функции.

Т.к. многократные перенаправления реализованы в виде простых списков объектов, перенаправления, используемые для их отмены, хранятся в отдельном списке. Этот список обрабатывается, когда выполнение команды завершено, но об этом должна позаботиться командная оболочка, поскольку перенаправления, используемые с функцией командной оболочки или со встроенной функцией ".", могут продолжать действовать до тех пор, пока функция не будет завершена. Когда происходит обращение к встроенной функции exec, причем она не вызывается как команда, то список отмены перенаправлений будет просто удаляться, поскольку перенаправления, связанные с exec, запоминаются и хранятся в среде командной оболочки без их отмены.

Другая сложность связана с самой оболочкой bash. В ранее использовавшихся версиях оболочки Bourne пользователю разрешалось обращаться только к дескрипторам 0 - 9, дескрипторы 10 и выше были зарезервированы для внутреннего использования в самой оболочке. В bash это ограничение ослаблено — пользователь может манипулировать дескриптором с любым номером вплоть до предела, обусловленного ограничением на количество открытых в процессе файлов. Это значит, что bash должен следить за дескрипторами файлов, открытых для его собственных внутренних нужд, в том числе и тех, что были открыты внешними библиотеками, а не только непосредственно самой оболочкой, и иметь возможность при необходимости переместить эти дескрипторы. Для этого требуется учитывать многое, в некоторых эвристиках нужно использовать флаг close-on-exec, и в течение всего времени, пока выполняется команда, необходимо поддерживать еще один список перенаправлений, который затем будет обработан или будет просто удален.

3.6.2. Встроенные команды

В bash есть ряд команд, которые являются частью самой оболочки. Эти команды выполняются самой командной оболочкой без создания нового процесса.

Самым распространенным мотивом сделать команду внутренней является возможность поддержки или изменения внутреннего состояния командной оболочки. Хорошим примером является команда cd; в одном из классических упражнений на вводных занятиях по Unix объясняется, почему команду cd нельзя реализовывать как внешнюю.

Встроенные команды bash используют те же самые внутренние примитивы, что и остальная часть командной оболочки. Каждая втроеннная команда реализована с помощью функций языка C, которая в качестве аргументов используется список слов. Это те слова, которые поступают со стадии раскрытия строк; встроенные команды рассматривают их как имена команд и аргументы. По большей части, встроенные команды используют те же самые стандартные правила раскрытия, что и другие команды, но с некоторыми исключениями: встроенные команды bash, в которых в качестве аргументов допускаются инструкции присваивания (например, declare и export), применяют для аргументов с инструкцией присваивания те же самые правила раскрытия, которые оболочка применяет при присваивании значений переменным. Это единственное место, где для передачи информации от одной стадии внутреннего конвейера командной оболочки к другой используется элемент flags из структуры WORD_DESC.

3.6.3. Выполнение простых команд

Простые команды — это те, которые встречаются чаще всего. Команда ищется в файловой системе, затем - исполняется, а после ее завершения определяется статус завершения, используемый при доступе ко многим другим возможностям командной оболочки.

Присваивание значения переменной в командной оболочке (т.е. слова вида var=value) само является своего рода простой командой. Инструкции присваивания могут либо предшествовать имени команды, либо находиться в отдельной командной строке. Если они предшествуют команде, то переменные передаются в исполняемую команду через среду окружения команды (если они предшествуют встроенной команде или функции командной оболочки, то они сохраняются, за немногими исключениями, лишь пока исполняется встроенная команда или функция). Если за инструкциями присваивания нет имени команды, то эти инструкции изменяют состояние командной оболочки.

Если представлено имя команды, которая не является именем функции командной оболочки или встроенной команды, то bash ищет в файловой системе исполняемый файл с таким именем. Для поиска будет использована значение переменной PATH, представляющее собой список каталогов, разделенных символами двоеточия. Поиск команд с именами, в которых есть слеш (или символы других разделителей каталогов) не происходит — они сразу передаются на исполнение.

Если команда найдена с использованием переменной PATH, то bash сохраняет имя команды и соответствующий полный путь в хеш-таблице, к которой он обратится в следующий раз перед следующим поиском в переменной PATH. Если команда не найдена, то bash выполняет функцию со специальным именем, если он ее найдет, а в качестве аргументов возьмет имя команды, которая должна была быть выполнена, и список ее аргументов. В некоторых дистрибутивах Linux это свойство используется для того, чтобы устанавливать недостающие команды.

Если bash находит файл, который должен быть выполнен, то процесс разветвляется на два процесса, создается новая среда исполнения и выполнение файла происходит в этой новой среде. Новая среда исполнения будет точной копией среды исполнения командной оболочки с незначительными отличиями, связанными с сигналами и открытием или закрытием файлов при перенаправлении.

3.6.4. Управление заданиями

Командная оболочка может выполнять команды в приоритетном режиме, в котором она ждет, пока команда завершится и получает статус выхода из команды, или в фоновом режиме, в котором командная оболочка сразу переходит к чтению следующей команды. Благодаря механизму управления заданиями можно перемещать процессы (команды, которые выполняются) между приоритетным и фоновым режимами, а также приостанавливать и возобновлять их исполнение. Для реализации этого в bash вводится понятие задания (job), которое, по существу является командой, исполняемой одним или несколькими процессами. В конвейере, например, для каждого элемента конвейера используется один процесс. Несколько отдельных процессов можно объединить в группу в виде одного задания. Терминалу будет назначен идентификатор группы процессов, связанных с этим терминалом, так что группа процессов, работающая в приоритетном режиме, эта та группа, идентификатор которой, такой же, как и у терминала.

В командной оболочке для реализации управления заданиями используется несколько простых структур данных. Есть структура для представления дочернего процесса, в которой хранится идентификатор процесса, его состояние и состояние, которое он возвращает при завершении. Конвейер является всего лишь простым связным списком, состоящим из таких структур. Задание очень похоже: есть список процессов, некоторое состояние задания (задание исполняется, приостановлено, завершено и т.д.), и идентификатор группы процессов задания. Список процессов обычно состоит из одного процесса; с заданием связано более одного процесса только для конвейера. Каждое задание имеет уникальный идентификатор группы процессов, а процесс в задании, идентификатор процесса которого такой же, как идентификатор группы процессов задания, называется лидером группы процессов. Текущий набор заданий хранится в массиве, концептуально очень похожем на то, как это представляет пользователь. Состояние задания и состояния выхода из задания формируются путем агрегирования состояний входящих в него процессов и состояний, возвращаемых этими процессами при их завершении.

Как и со многим другим в командной оболочке, сложной частью, касающейся реализации управления заданиями, является учет. Оболочка должна позаботиться о назначении процессов правильным группам процессов, удостовериться, что синхронизировано создание дочернего процесса и его назначение группе процессов и что надлежащим образом задана группа процессов терминала, поскольку группа процессов терминала определяет задание, работающее в приоритетном режиме (и если его не вернуть его обратно группе процессов облочки, то оболочка сама не сможет прочитать ввод с терминала). Поскольку учет очень сильно ориентирован на процессы, не так легко реализовать составные команды, например, циклы while и for, поскольку весь цикл должен останавливаться и запускаться как единое целое, и это должно быть сделано в нескольких командных оболочках.

3.6.5. Составные команды

Составные команды состоят из списка одной или нескольких простых команд и начинаются с ключевого слова, например, if или while. Именно здесь видна и эффективна вся мощь программирования командной оболочки.

Реализация довольно проста. Синтаксический анализатор строит объекты, соответствующие различным составным командам, и интерпретирует их по мере обхода объектов. Каждая составная команда реализуется с помощью соответствующей функции языка C, которая отвечает за выполнение надлежащих раскрытий, исполнения команд так, как это указано, и изменения порядка исполнения в соответствие со статусом возврата команд. В качестве иллюстрации рассмотрим функцию, с помощью которой реализована команда for. Она сначала должна раскрыть список слов, следующих за зарезервированным словом in. Затем функция должна выполнить итерацию по раскрытым словам, назначая каждое слово соответствующей переменной, а затем исполнить список команд, указанных в теле команды for. Команда for не должна изменять порядок исполнения в соответствие со статусом возврата команд, но она должна обращать внимание на встроенные команды break и continue. После того, как все слова в списке будут использованы, произойдет выход из команды for. Видно, что реализация, большей частью, весьма близка к описанию команды.

3.7. Усвоенные уроки

3.7.1. Что, по моему мнению, важно

Я потратил более двадцати лет на работу над bash и, как мне бы хотелось думать, обнаружил кое-что интересное. Самое главное, что я не могу переоценить, то, что очень важно иметь подробные журналы изменений. Хорошо, когда вы можете посмотреть в журнал и вспомнить, почему было сделано конкретное изменение. Еще лучше, если вы можете дополнить это изменение конкретным сообщением об ошибке, тестом, который можно будет повторить, или советом.

Если необходимо тщательное регрессионное тестирование, то это именно то, что я бы порекомендовал делать в проекте с самого начала. В bash есть тысячи тестов, охватывающий практически все его неинтерактивные возможности. Мне надо было создавать тесты для интерактивных функций - в Posix они есть в тестовом фреймворке, но не хотелось иметь дистрибутив, в котором мне бы пришлось принять решение его использовать.

Стандарты важны. Bash выигрывает от того, что реализован в соответствие со стандартом. Важно участвовать в стандартизации программы, которую вы реализуете. Кроме обсуждений возможностей и функций, наличие стандарта позволит использовать стандарт в качестве арбитра. Конечно, стандарт также может оказаться плохим, это зависит от самого стандарта.

Внешние стандарты важны, но также хорошо иметь внутренние стандарты. Мне посчастливилось воспользоваться набором стандартов проекта GNU, в которых предлагаются достаточно хорошие практические советы по разработке и реализации программ.

Еще один важный аспект - хорошая документация. Если вы предполагаете, что программой будут пользоваться другие, важно иметь исчерпывающую и ясно написанную документацию. Если программа будет успешно использоваться, то, в конечном итоге, документации для нее будет много, но важно, чтобы разработчик написал свою собственную версию.

Есть очень много хорошего программного обеспечения. Пользуйтесь всем, чем можете: например, в gnulib есть много удобных библиотечных функций (если только вы сможете извлечь их из фреймворка gnulib). Так делают в системах BSD и Mac OS X. Пикассо как-то по случаю сказал: "У великих художников воруют".

Привлекайте к участию сообщество пользователей, но будьте готовы к периодическим критическим замечаниям, некоторые из которых заставят вас сильно задуматься. Активное сообщество пользователей может быть огромным преимуществом, но одно из последствий состоит в том, что эти люди очень страстные. Не воспринимайте это как личное.

3.7.2. Что я должен был бы сделать по-другому

У bash миллионы пользователей. Я знаю о важности обратной совместимости. В некотором смысле обратная совместимость означает, что вам никогда не придется извиняться. Но мир не так прост. Время от времени я вынужден был делать несовместимые изменения, почти из-за каждого из них от пользователей поступало некоторое количество жалоб, хотя у меня всегда было то, что я считал уважительной причиной, будь то замена плохого решения, которое исправляло неправильную работу, или корректировка несоответствий между различными частями командной оболочки. Я должен был бы раньше ввести что-то вроде формальных уровней совместимости bash.

Разработка bash никогда не была особенно открытой. Я привык к использованию промежуточных релизов (например, bash-4.2) и отдельно разработанных патчей. Для этого есть причины: я подстроился под поставщиков с их сроками выпуска релизов, более длинными, чем выпуск релизов в мире бесплатного программного обеспечения и открытого исходного кода, и у меня в прошлом были проблемы с бета-версиями, которые становились все более распространенными, чем мне бы того хотелось. Хотя, если мне пришлось бы начать все заново, я бы предпочел более частые релизы и пользовался одним из вариантов публичного репозитория.

Но этот список был бы неполным, если не коснуться реализации bash. Есть вопрос, к которому я обращался несколько раз, но не приступил к его решению, - это полное переписывание синтаксического анализатора bash с использованием прямого рекурсивного спуска, а не с использованием bison. Когда-то я думал, что я должен сделать это с тем, чтобы привести подстановку команд в соответствие с POSIX, но я мог бы сделать это, если бы не изменения, которых было очень много. Если бы я начал писать bash с нуля, я бы, наверное, написал синтаксический анализатор вручную. Это, конечно, сделало бы его в некоторых местах проще.

3.8. Заключение

Bash является хорошим примером большого и сложного куска свободного программного обеспечения. Его преимущество в том, что он разрабатывался более двадцати лет и стал сформировавшимся и мощным проектом. Он работает практически везде, и им каждый день пользуются миллионы людей, причем многие из них даже не подозревают об этом.

На bash повлияли многие проекты, начиная с седьмой редакции оригинальной командной оболочки Unix, написанной Стивеном Борном (Stephen Bourne). Наиболее существенное влияние оказал стандарт Posix, в котором определена значительная часть функциональных возможностей bash. Подобное сочетание обратной совместимости и соответствие стандартам добавило свои собственные проблемы.

Bash получает преимущество от того, что является частью проекта GNU, определяющего направление развития и границы, в которых существует bash. Без проекта GNU, не было бы никакого bash. Bash также выигрывает благодаря тому, что у него есть активное быстро реагирующее сообщество пользователей. Их отзывы помогли сделать bash тем, чем сегодня он стал, что свидетельствует о преимуществах свободного программного обеспечения.

Примечания:

  1. Чаще всего — последовательность, состоящая из одного из таких повторяющихся символов.
  2. Встроенная команда exec является исключением из этого правила.

Creative Commons

Перевод был сделан в соответствие с лицензией Creative Commons. С русским вариантом лицензии можно ознакомиться здесь.

4.1. В начале

Berkeley DB восходит к эпохе, когда операционная система Unix была собственностью компании AT&T и были сотни утилит и библиотек, в родословных которых присутствовали строгие лицензионные ограничения. Марго Зельцер (Margo Seltzer) была аспирантом Калифорнийского университета в Беркли, а Кейт Бостик (Keith Bostic) был членом группы Computer Systems Research Group. В то время Кейт работал над удалением проприетарного программного обеспечения AT&T из дистрибутива Berkeley Software.

Проект Berkeley DB начинался со скромной задачи замены хэш-пакета hsearch, работающего в памяти, и хэш-пакетов dbm/ndbm, работающих с диском, новыми пакетами, имеющими лучшую реализацию хэш-функции, работающими как в памяти, так и с диском, и свободно распространяемыми без проприетарной лицензии. В основе библиотеки hash, написанной Марго Зельцер [SY91], лежало исследование линейно расширяемых хэш-таблиц, выполненное Витольдом Литвином (Witold Litwin). Библиотека была уникальна своей искусной схемой, позволяющей получать константное время отображения между хэш-значениями и адресами страниц, а также своей возможностью обрабатывать большие объемы данных — элементы, размер которых больше размера используемых для этого хэш-блоков или страниц файловой системы, размер которых обычно составляет от четырех до восьми килобайтов.

Если хэш-таблицы – это хорошо, то деревья Btrees и хэш-таблицы – это еще лучше. Майк Олсон (Mike Olson), также аспирант Калифорнийского университета в Беркли, написал ряд реализаций деревьев Btree и согласился написать еще одну. Мы втроем преобразовали программу, созданную Марго для хэш-таблиц, и программу для работы с деревьями Btree, созданную Майком, в интерфейс API, не зависящий от метода доступа, с помощью которого приложения обращались к хэш-таблицам или деревьям Btrees через специальные средства работы с базами данных, в которых были методы обработки, позволяющие читать и модифицировать данные.

Создавая эти два метода доступа, Майк Олсон и Марго Зельцер опубликовали научную статью ([SO92]), описывающую LIBTP, транзакционную библиотеку программного уровня, которая работает в адресном пространстве приложений.

Библиотеки хэш-таблиц и деревьев Btree были включены в окончательные релизы 4BSD под названием Berkeley DB 1.85. Технически, в методе доступа Btree были реализованы деревья вида B+link (двоичные деревья + ссылки), однако, в оставшейся части настоящей статьи мы будем пользоваться термином Btree, поскольку это название метода доступа. Вероятно, каждому, кто пользовался какой-нибудь системой, базирующейся на Linux или BSD, знакомы структура и интерфейсы API Berkeley DB 1.85.

Библиотека Berkeley DB 1.85 не изменялась в течение нескольких лет до тех пор, пока в 1996 году компания Netscape не заключила с Марго Зельцер и Кейт Бостик контракт на разработку полностью транзакционной схемы, описанной в статье о LIBTP, и на создание версии приложения промышленного уровня. В результате этих усилий была создана первая транзакционная версия Berkeley DB - версия 2.0.

Последующая история Berkeley DB проще и более традиционна: в Berkeley DB 2.0 (1997) были предложены транзакционные операции для Berkeley DB; Berkeley DB 3.0 (1999) была вновь версией, которую перепроектировали и в которую были добавлены дополнительные уровни абстракции, что косвенно привело к росту функциональных возможностей. В Berkeley DB 4.0 (2001) была представлена репликация и реализована высокая степень готовности, а в Oracle Berkeley DB 5.0 (2010) была добавлена поддержка SQL.

К моменту написания данной главы Berkeley DB является самым широко используемой в мире инструментальным набором, предназначенным для работы с базами данных, с сотнями миллионов установленных копий, работающих на всем, начиная от маршрутизаторов и браузеров и до почтовых программ и операционных систем. Несмотря на то, что проекту более двадцати лет, базовый инструментальный набор и объектно-ориентированный подход, используемый в Berkeley DB, позволяют поэтапно улучшать и перерабатывать проект в соответствие с требованиями программ, в которых он используется.

Первый урок конструирования

Для тестирования и сопровождения любого сложного программного пакета жизненно важно, чтобы он был спроектирован и собран в виде набора взаимодействующих модулей с хорошо определенными границами действия API. Границы могут (и должны!) сдвигаться по мере необходимости, но они всегда должны в нем присутствовать. Существование этих границ не позволит программному пакету превратиться в клубок спагетти, который невозможно будет поддерживать. Батлер Лампсон однажды сказал, что все проблемы в области компьютерных наук можно решить с помощью еще одного уровня косвенности. Если конкретно, то когда его спросили, что это значит с точки зрения объектной ориентированности, Лампсон ответил, что это означает, что за API могут быть несколько реализаций. Этот подход, позволяющий иметь за единым интерфейсом несколько реализаций, был воплощен в общей схеме и в реализации Berkeley DB, что позволило реализовать объектно-ориентированную систему несмотря на то, что библиотека написана на языке C.

4.2. Обзор архитектуры

В этом разделе мы рассмотрим архитектуру библиотеки Berkeley DB, причем начнем с LIBTP и укажем ключевые аспекты ее эволюции.

На рис.4.1, который взят из оригинальной статьи Зельцер и Олсона, показана первоначальная архитектура LIBTP, а на рис.4.2 приведена архитектура Berkeley DB 2.0.

Рис.4.1: Архитектура прототипной системы LIBTP

Рис.4.2: Архитектура Berkeley DB-2.0, которую предполагалось реализовать

Единственным существенным различием между реализацией LIBTP и архитектурой Berkeley DB 2.0 было удаление менеджера процессов. Вместо того, чтобы использовать синхронизацию на уровне подсистемы, в LIBTP требовалось, чтобы каждый поток управления регистрировал себя в библиотеке, а затем синхронизировался с отдельными потоками / процессами. Как будет рассказано в разделе 4.4, первоначальная архитектура, возможно, была для нас более предпочтительной.

Рис.4.3: Архитектура Berkeley DB 2.0.6, которая была фактически реализована

Различие между проектом и фактической реализацией архитектуры DB-2.0.6, приведенной на рис.4.3, иллюстрирует реальность реализации надежного менеджера восстановлений. Подсистема восстановления показана серым цветом. В ее состав входят как инфраструктура драйверов, находящаяся в блоке «Recovery» («Восстановление»), так и и набор процедур redo и undo, осуществляющих восстановление после операций, выполняемых методами доступа. Они представлены в овале с надписью «Аccess method recovery routines» («Процедуры восстановления действий, выполняемых методами доступа»). В Berkeley DB 2.0 есть единая схема, используемая при восстановлении, в отличие от жестко закодированных процедур журналирования и восстановления для конкретных методов доступа, что было сделано в LIBTP. Такая универсальная схема также позволила создать более богатый интерфейс между различными модулями.

На рис.4.4 проиллюстрирована архитектура Berkeley DB-5.0.21. Цифры на диаграмме указывают интерфейсы API, перечисленные в таблице 4.1. Хотя первоначальная архитектура все еще просматривается, в нынешней архитектуре видно ее развитие благодаря тому, что добавлены новые модули, проведена декомпозиция старых модулей (например, модуль log был преобразован в модули log и dbreg), а также существенно увеличено количество межмодульных API.

Рис.4.4: Архитектура Berkeley DB-5.0.21

Таблица 4.1: Интерфейсы API в Berkeley DB 5.0.21

Интерфейсы API для приложений

1. Операции обработки DBP 2. Восстановление DB_ENV 3. Транзакционные интерфейсы API
open open(… DB_RECOVER …) DB_ENV->txn_begin
get DB_TXN->abort
put DB_TXN->commit
del DB_TXN->prepare
cursor

Интерфейсы API, используемые в методах доступа

4. Into Lock 5. Into Mpool 6. Into Log 7. Into Dbreg
__lock_downgrade __memp_nameop __log_print_record __dbreg_setup
__lock_vec __memp_fget __dbreg_net_id
__lock_get __memp_fput __dbreg_revoke
__lock_put __memp_fset __dbreg_teardown
__memp_fsync __dbreg_close_id
__memp_fopen __dbreg_log_id
__memp_fclose
__memp_ftruncate
__memp_extend_freelist

Интерфейсы API процесса восстановления

8. Into Lock 9. Into Mpool 10. Into Log 11. Into Dbreg 12. Into Txn
__lock_getlocker __memp_fget __log_compare __dbreg_close_files __txn_getckp
__lock_get_list __memp_fput __log_open __dbreg_mark_restored __txn_checkpoint
__memp_fset __log_earliest __dbreg_init_recover __txn_reset
__memp_nameop __log_backup __txn_recycle_id
__log_cursor __txn_findlastckp
__log_vtruncate __txn_ckp_read

Интерфейсы API, используемые модулем транзакций

13. Into Lock 14. Into Mpool 15. Into Log 16. Into Dbreg
__lock_vec __memp_sync __log_cursor __dbreg_invalidate_files
__lock_downgrade __memp_nameop __log_current_lsn __dbreg_close_files
__dbreg_log_files

Интерфейс API Into системы репликации

17. From Log 18. From Txn
__rep_send_message __rep_lease_check
__rep_bulk_message __rep_txn_applied
__rep_send_message

Интерфейс API From системы репликации

19. Into Lock 20. Into Mpool 21. Into Log 22. Into Dbreg 23. Into Txn
__lock_vec __memp_fclose __log_get_stable_lsn __dbreg_mark_restored __txn_recycle_id
__lock_get __memp_fget __log_cursor __dbreg_invalidate_files __txn_begin
__lock_id __memp_fput __log_newfile __dbreg_close_files __txn_recover
__memp_fsync __log_flush __txn_getckp
__log_rep_put __txn_updateckp
__log_zero
__log_vtruncate

Таблица 4.1: Интерфейсы API в Berkeley DB 5.0.21

Спустя более десяти лет эволюции, десятков коммерческих релизов, а также добавления сотен новых функций мы видим, что архитектура стала существенно более сложной, чем ее предшественница. Ключевые моменты, которые необходимо отметить, следующие: во-первых, репликация добавила в систему совершенно новый слой, но это сделано аккуратно, так что взаимодействие с остальной частью системы осуществляется через те же самые API, что и исторически сложившийся код. Во-вторых, модуль log был разделен на модули log и dbreg (регистрация базы данных). Этот вопрос будет более подробно рассмотрен в разделе 4.8. В-третьих, для того, чтобы в приложениях не было коллизий с именами наших функций, мы поместили все межмодульные вызовы в пространство имен, в котором в именах используется предваряющий символ подчеркивания. Мы обсудим этот вопрос в Шестом уроке конструирования.

В-четвертых, сейчас интерфейс API подсистемы журналирования базируется на использовании курсоров (это не интерфейс API log_get, он заменен интерфейсом API log_cursor). Исторически сложилось так, что Berkeley DB никогда в любой момент времени не использовалось более одного потока чтения или записи журнала, поэтому в библиотеке имелось только одно понятие текущего указателя поиска в журнале. С точки зрения абстрагирования это никогда не считалось хорошим решением, но при наичии репликации оно стало неприемлемым. Точно также, как в интерфейсе приложений API поддерживается итерация с использованием курсоров, так и в журнале теперь итерация выполняется с применением курсоров. В-пятых, модуль fileop, входящий в состав модулей доступа, обеспечивает в транзакционно защищенной базе данных поддержку операций создания, удаления и переименования. Нам потребовалось много попыток с тем, чтобы сделать реализацию приемлемой (она еще не так аккуратна, как этого бы хотелось), а после переделок ее в течение длительного времени мы выделили ее в отдельный модуль.

Второй урок конструирования

Программный проект является просто одним из нескольких способов, которые заставляют вас подумать о всей проблеме в целом прежде, чем вы ее пытаетесь решить. Опытные программисты для этого используют различные методики: некоторые пишут первую версию и выбрасывают ее, некоторые пишут множество страниц руководств или проектной документации, другие заполняют некоторый шаблон, в котором каждое требование определяется, назначается определенной функции или комментируется. Например, в Berkeley DB, мы прежде, чем писать какой-либо код, создавали полный набор справочных страниц в стиле Unix для всех методов доступа и для всех компонентов, лежащих в их основе. Вне зависимости от используемой методики, трудно ясно разобраться в архитектуре программы после того, как начинается отладка кода, не говоря уже о том, что трудно делать крупные изменения в архитектуре, при которых часто зря пропадают усилия, ранее затраченные на отладку. Создание архитектуры программы требует другого склада ума, отличающегося от того, который нужен при отладке кода, и архитектура, которая у вас есть к моменту, когда вы начинаете отладку, как правило, именно та, которую вы реализовали в конкретной версии.

Почему вся архитектура библиотека транзакций собрана из компонентов, а не заточена на один из вариантов предполагаемого использования? На этот вопрос есть три ответа. Во-первых, это повышает дисциплину проектирования. Во-вторых, без четко определенных границ в коде сложные программные пакеты неизбежно выродятся в груду кода, который невозможно будет поддерживать. В-третьих, вы никогда не можете предвидеть все способы использования клиентами вашего программного обеспечения; если вы предоставите пользователям больше возможностей, позволив им получать доступ к компонентам, они будут пользоваться ими так, как вы никогда на это не рассчитывали.

В следующих разделах мы рассмотрим все компоненты Berkeley DB, разберемся, что они делают и как они вписываются в общую картину.

4.3. Методы доступа: Btree, Hash, Recno, Queue

Методы доступа в Berkeley DB позволяют осуществлять поиск как по ключу, так и методом итерации, и использовать байтовые строки переменной и фиксированной длины. В методах доступа BTree и Hash поддерживается использование пар ключ/значение с переменной длиной строк. В Recno и Queue поддерживается использование пар номер-записи/значение (причем Recno поддерживает использование значений переменной длины, а Queue поддерживает значения только фиксированной длины).

Основное различие между методами доступа BTree и Hash в том, что в BTree для ключей предлагается локализация ссылок тогда, как в Hash - нет. Это означает, что BTree является правильным методом доступа почти для всех наборов данных; но метод доступа Hash пригоден для таких больших наборов данных, для которых даже структура индексов BTree не поместится в память. В таком случае память лучше использовать для данных, а не для структур индексов. Этот компромисс имел гораздо больше смысла в 1990 году, когда оперативной памяти было обычно намного меньше, чем сегодня.

Разница между методами Recno и Queue в том, что в Queue поддерживается блокировка на уровне записей, из-за чего можно использовать значения только фиксированной длины. В Recno можно использовать объекты переменной длины, но, как и в случае с BTree и Hash, поддерживается блокировка записей только на уровне страниц.

Первоначально мы разрабатывали Berkeley DB таким образом, чтобы функции CRUD (функции Create - создание, Read - чтение, Update - обновление и Delete - удаление) использовали ключи, чтобы приложениям был предоставлен простейший интерфейс. Впоследствии для поддержки итерации мы добавили курсоры. Такая последовательность разработки привела к беспорядку и расточительным случаям, когда внутри библиотеки дублировались большие фрагменты путей исполнения кода. Со временем это привело к невозможности осуществлять поддержку и мы преобразовали все операции с ключами в операции с курсорами (операции с использованием ключей теперь вызывают из кэша курсор, выполняют операцию и возвращают курсор в пул курсоров). Это один из примеров бесконечно повторяемых правил разработки программного обеспечения: не оптимизировать порядок исполнения кода, если это ухудшает его понятность и увеличивает сложность, до тех пор, пока вы не будете знать, что необходимо сделать именно так.

Третий урок конструирования

Архитектура программы ухудшается не из-за того, что она постепенно стареет. Архитектура программы ухудшается прямо пропорционально количеству вносимых в программу изменений: исправление ошибок ухудшает деление на слои, а новые возможности оказывают воздействие на всю конструкцию. Решение о том, что архитектура программы ухудшилась настолько, что вы должны изменить всю конструкцию или переписать модуль, является тяжелым решением. С одной стороны, поскольку архитектура ухудшилась, сопровождение и дальнейшее разработка программы становятся все труднее, и в конце концов сопровождение устаревшего фрагмента можно поддерживать в каждом релизе только с привлечением грубой силы тестировщиков, поскольку никто не понимает, как программа работает внутри. С другой стороны, пользователи будут горько жаловаться на нестабильность и несовместимости, которые являются следствием фундаментальных изменений. Независимо от того, какой путь вы выбирете, вы, как архитектор программы, можете лишь гарантировать, что кто-нибудь будет недоволен.

Мы опускаем подробное обсуждение внутренних особенностей методов доступа в Berkeley DB; они реализуют довольно известные алгоритмы Btree и хэширования (Recno является слоем над кодом Btree, а Queue является функцией поиска файловых блоков, хотя и усложненной за счет добавления блокировок на уровне записей).

4.4. Интерфейсный слой библиотеки

Со временем, когда мы добавили дополнительные функциональные возможности, мы обнаружили, что как для приложений, так и для внутреннего кода нужны одни и те же функции высокого уровня (например, для выполнения операции объединения таблиц необходимо несколько курсоров для итерации по строкам точно также, как и в приложение может использоваться курсор для итерации по тем же самым строкам).

Четвертый урок конструирования

Не имеет значения, как вы называете свои переменные, методы, функции, что комментируете или какой стиль кодирования вы используете; то есть, существует большое количество форматов и стилей, которые считаются «достаточно хорошими». Что действительно значимо, причем имеет большое значение, это чтобы имена и стиль были согласованными. Опытные программисты получают массу сведений из текста кода и именования объектов. Вы должны следить за тем, чтобы не было несоответствия между именами и стилем, поскольку некоторые программисты тратят время и усилия на то, чтобы не передавать другим верные сведения, и наоборот. Несоблюдение принятых соглашений о кодировании является тяжким преступлением.

По этой причине, мы разбили интерфейсы API методов доступа на строго определенные слои. В этих слоях, содержащих интерфейсные процедуры, выполняются все необходимые общие проверки ошибок, проверки ошибок конкретных функций, отслеживается работа интерфейса, а также выполняются другие задачи, такие как автоматическое управление транзакциями. Когда приложения обращаются к Berkeley DB, они обращаются к интерфейсным процедурам первого уровня, использующим метоты работы с дескрипторами объектов. (Например, __dbc_put_pp является обращением к интерфейсу вызова метода «put», использующего курсор Berkeley DB, с помощью которого обновляется элемент данных. «_pp» является суффиксом, используемым нами для идентификации всех функций, к которым могут обращаться приложения).

Одной из задач Беркли DB, которые выполняются в интерфейсном слое, является отслеживание запуска потоков исполнения внутри библиотеки Berkeley DB. Это необходимо потому, что некоторые внутренние операции Berkeley DB можно выполнять только тогда, когда ни один из потоков не работает внутри библиотеки. Отслеживание работы потоков в библиотеке Berkeley DB реализуется с помощью установки флага, указывающего, что внутри библиотеки был запущен поток, в начале каждого библиотечного API и сброса этого флага в момент, когда происходит выход из вызова этого API. Такая проверка входа/выхода всегда выполняется в интерфейсном слое, поскольку она эквивалентна проверке, определяющей, выполнен ли вызов в реплицируемой среде.

Возникает естественный вопрос: «а почему бы не передать идентификатор потока внутрь библиотеки, не будет ли это проще»? Ответ – да, было бы гораздо проще, и мы, конечно, хотели сделать именно так. Но такое изменение потребовало бы изменить каждое отдельное приложение Berkeley DB и большинство обращений каждого из приложений к Berkeley DB, а во многих случаях также потребовалось бы переделать структуру приложений.

Пятый урок конструирования

Архитекторы программ должны аккуратно относиться к обновлениям в реально используемых проектах: пользователи воспримут нормально незначительные изменения, осуществляемые при обновлениях до новых версий (если вы гарантируете отсутствие ошибок времени компиляции, то есть отсутствие очевидных отказов до тех пор, пока не будет завершено обновление; изменения при обновлении никогда не должны приводить к плохо диагностируемым отказам). Но чтобы сделать по-настоящему фундаментальные изменения, вы должны решиться на использование нового кода базы данных и потребовать от ваших пользователей портировать их базы данных. Очевидно, что новый код и портирование являются не дешевыми с точки зрения затраты времени или ресурсов, но никто из ваших пользователей не рассердится, если вы расскажете им, что огромный капитальный ремонт является в действительности незначительным обновлением.

Другой задачей, выполняемой в интерфейсном слое, является создание транзакций. Библиотека Berkeley DB поддерживает режим, в котором каждая операция выполняется в рамках автоматически создаваемой транзакции (это освобождает приложение от создания и подтверждение своих собственных явно указываемых транзакции). Для поддержки этого режима требуется, чтобы транзакция создавалась автоматически каждый раз, когда приложение осуществляет обращение через API и не указывает, что оно использует свои собственные транзакции.

Наконец, во всех интерфейсах API в Berkeley DB API необходима проверка аргументов. В Berkeley DB есть два вида проверки ошибок - общая проверка, которая определяет, была ли наша база данных повреждена во время предыдущей операции и не находимся ли мы в процессе изменения реплицируемого состояния (например, выполняются изменения, при которых можно писать реплики). Есть также конкретные проверки для интерфейса API: правильность использования флага, правильность использования параметров, правильность комбинации параметров, а также отсутствие любых других видов ошибок, что мы должны поверить перед тем, как действительно будем выполнять требуемую операцию.

Эти проверки, необходимые для определенных API, инкапсулированных внутри функций, имеющих суффикс _arg. Таким образом, проверка ошибок для метода put, используемого с курсором, находится в функции __dbc_put_arg, которая вызывается из функции __dbc_put_pp.

Наконец, когда будет завершены все проверки аргументов и будет создана транзакция, мы вызываем рабочий метод, который фактически выполняет операцию (в нашем примере это будет __dbc_put), которая будет той же самой функцией, которую мы используем, когда обращаемся внутри библиотеки к функции put курсора.

Эта декомпозиция была создана в период интенсивной деятельности, когда мы пытались точно определить, какие действия мы должны выполнять при работе в реплицируемых средах. После того, как мы некоторое достаточно большое количество раз повторно просмотрели код, мы выделили всю эту предварительную проверку отдельно с тем, чтобы следующий раз, когда мы обнаружим, что проблема в ней, ее было проще изменять.

4.5. Компоненты, лежащие глубже

Есть четыре компонента, лежащие в основе методов доступа: менеджер буферирования, менеджер блокировок, журнальный менеджер и менеджер транзакций. Мы обсудим каждый из них в отдельности, но у них всех есть некоторые общие архитектурные особенности.

Во-первых, во всех подсистемах имеются свои собственные API, и первоначально в каждой подсистеме имелся свой собственный дескриптор объектов со всеми методами для конкретной подсистемы, использовавший этот дескриптор. Например, вы могли использовать менеджер блокировок из Berkeley DB для обработки ваших собственных блокировок или могли написать свой собственный отдельный менеджер блокировок, либо вы могли использовать менеджер буферирования из Berkeley DB для того, чтобы обрабатывать страницы ваших собственных файлов в разделяемой памяти. Со временем для того, чтобы упростить приложения Berkeley DB, дескрипторы, предназначенные для использования в конкретных подсистемах, были удалены из интерфейса API. Хотя подсистемы все еще являются отдельными компонентами, которые можно использовать независимо от других подсистем, теперь у них всех есть общее средство управления объектами — средство управления "средой" DB_ENV. Эта архитектурная особенность позволяет выделять слои и делать обобщения. Даже несмотря на то, что время от времени слой меняет свое положение, и все еще есть несколько мест, где к некоторой подсистеме можно получить доступ через другую подсистему, программисты могут рассматривать каждую часть системы как отдельный программный продукт с его собственными возможностями.

Во-вторых, все подсистемы (на самом деле, все функции Berkeley DB) возвращают коды ошибок через стек вызовов. Berkeley DB, как библиотека, не может для объявления глобальных переменных пользоваться пространством имен приложения, не говоря уже о том, что возврат через стек вызовов в случае ошибок является хорошей дисциплиной для программиста

Шестой урок конструирования

Когда создается библиотека, жизненно важно не нарушать пространство имен. Программисты, использующие вашу библиотеку, не должны запоминать десятки зарезервированные имен функций, констант, структур и глобальных переменных для того, чтобы избежать конфликтов имен между приложением и библиотекой.

Наконец, все подсистемы поддерживают использование разделяемой памяти. Поскольку в Berkeley DB поддерживается разделение баз данных между несколькими работающими процессами, все интересуемые структуры данных должны находиться в разделяемой памяти. Наиболее значимым следствием такого решения является то, что для того, чтобы структуры данных, использующие указатели, работали в контексте нескольких процессов, в структурах данных, располагаемых в памяти, должны использоваться пары базовый адрес и смещение, а не указатели. Другими словами, вместо косвенного доступа через указатель, библиотека Berkeley DB должна создавать указатель из базового адреса (адреса, по которому сегмент разделяемой памяти отображается в память) и смещения (смещение конкретной структуры данных в соответствие с тем, как она отображена в сегменте). Для поддержки этой возможности мы написали версию пакета queue для дистрибутива Berkeley Software, в котором был реализован широкий спектр различных связанных списков.

Седьмой урок конструирования

Еще перед тем, как мы написали пакет связных списков для разделяемой памяти, инженеры Berkeley DB вручную кодировали разнообразные варианты различных структур данных, используемых в разделяемой памяти, и эти реализации рассыпались и их было трудно отлаживать. Пакет со списками для разделяемой памяти, сделанный наподобие пакетов списков BSD (queue.h), заменил все эти усилия. Как только пакет был отлажен, нам больше не потребовалось выполнять какие-либо другие отладки, касающиеся проблем связных списков в разделяемой памяти. Это иллюстрирует три важных принципа проектирования: Во-первых, если у вас есть функциональные фрагменты, которые появляются более одного раза, напишите разделяемые функции и используйте их, поскольку само по себе наличие в коде двух копий любых конкретных функциональных фрагментов гарантирует, что один из них будет реализован неправильно. Во-вторых, когда вы разрабатываете набор процедур общего назначения, напишите набор тестов для этого набора процедур для того, чтобы вы могли отладить их отдельно. В-третьих, чем труднее писать код, тем более важно чтобы он был написан и поддерживался отдельно; почти невозможно защитить окружающий код от инфицирования и раъедания со стороны некоторого фрагмента кода.

4.6. Менеджер буферирования: Mpool

Подсистема Mpool в Berkeley DB является размещаемым в памяти пулом буферов файловых страниц, скрывающим тот факт, что основная память является ограниченным ресурсом, для которого требуется библиотека, осуществляющая при работе с базами данных, размер которых больше размера памяти, перемещение страниц базы данных на диск и с диска в память. Кэширование страниц базы данных в памяти, было тем, что позволило исходной библиотеке хеширования значительно превзойти по производительности ранее использовавшиеся реализации hsearch и ndbm.

Хотя метод доступа Btree в Berkeley DB является достаточно традиционной реализацией B+tree, указатели между узлами дерева представлены в виде номеров страниц, а не в виде фактических указателей памяти, потому что реализация библиотеки использует формат данных, предназначенный для диска, также в качестве формата данных, используемого в памяти.

Есть и другие последствия влияния на производительность, обусловленные тем, что представление индексов Berkeley DB в памяти является на самом деле кэшем данных, постоянно хранящихся на диске. Например, всякий раз, когда Berkeley DB обращается к кэш-странице, она сначала фиксирует размещение страницы в памяти. Эта фиксация запрещает каким-либо другим потокам или процессам убирать эту страницу из пула буферов. Даже если индексная структура полностью помещается в кэш-памяти и ее не нужно сбрасывать на диск, Berkeley DB при каждом доступе, по-прежнему, сначала выполняет фиксацию страниц в памяти, а затем — их освобождение, поскольку согласно лежащей в основе модели, предлагаемой Mpool, это — кэш, а не постоянное хранилище данных.

4.6.1. Файловая абстракция Mpool

Предполагается, что Mpool находится на вершине файловой системы, экспортируя файловые абстракции через интерфейс API. Например, обработчики DB_MPOOLFILE, используемые для файлов, располагаемых на диске, представляют методы для перемещения страниц в файл и выборки страниц из файла. Хотя в Berkeley DB поддерживаются временные базы данных и базы данных, размещаемые только в памяти, для них также используются обработчики DB_MPOOLFILE из-за лежащей в их основе абстракций Mpool. Методы get и put являются основными в интерфейсах Mpool API: метод get гарантирует, что страница размещена в кэше, закрепляет страницу в памяти и возвращает указатель на эту страницу. Когда библиотека выполнит все действия с этой страницей, метод put отменяет закрепление этой страницы, что позволит удалять страницу из памяти. В ранних версиях Berkeley DB не было различия между закреплением страницы для чтения и закреплением страницы для записи. Однако для того, чтобы увеличить степень распараллеливания, мы расширили интерфейс Mpool API с тем, чтобы при обращении можно было сообщить о намерении обновить страницу. Эта возможность различать доступ для чтения от доступа для записи, имеет важное значение для реализации многовариантного управления распараллеливанием. Страница, закрепленная в памяти для чтения, если случится, что она изменится, может быть записана на диск, тогда как страница, закрепленная для записи, не может, поскольку она в любой момент может находиться в несогласованном состоянии.

4.6.2. Запись в журнал с упреждением

В Berkeley DB в качестве транзакционного механизма, позволяющего выполнить восстановление поле возможного отказа, используется запись в журнал с упреждением (или WAL - write-ahead-logging). Термин «запись в журнал с упреждением» определяет политику, требующую, чтобы записи в журнал, описывающие любые изменения, делались на диск перед фактическим обновлением данных, которые они описывают. Использование в Berkeley DB политики WAL в качестве транзакционного механизма имеет важные последствия для Mpool, и в Mpool приходится, с точки зрения проектирования, балансировать между реализацией общего механизма кэширования и необходимостью поддерживать протокол WAL.

В Berkeley DB на всех страницах данных записываются журнальные порядковые номера (номера LSN – log sequence number), позволяющие заносить в журнал записи, соответствующие самым последним обновлениям конкретной страницы. Чтобы реализовать политику WAL, нужно перед тем, как Mpool запишет какую-нибудь страницу на диск, удостовериться, что регистрационная запись, сооответствующая номеру LSN на странице, сохранена на диске. Задача проектирования состоит в том, чтобы реализовать эту функциональность так, чтобы ото всех клиентских программ Mpool не требовалось использовать формат страниц, идентичный используемому в Berkeley DB. В Mpool эта задача решается с помощью набора методов setget), которые обеспечивают необходимое поведение. Метод set_lsn_offset из DB_MPOOLFILE указывает смещение в байтах, где на странице Mpool должен искать номер LSN, необходимый для WAL. Если обращений к этому методу не было, то протокол WAL в Mpool не используется. Аналогичным образом метод set_clearlen сообщает Mpool, сколько байтов на странице занимают метаданные, которые нужно явно очистить, когда страница создается в кеше. Эти интерфейсы API позволяют реализовать в Mpool функции, необходимые для поддержки требований транзакционности, имеющиеся в Berkeley DB, не заставляя от всех пользователей Mpool реализовывать эти возможности.

Восьмой урок конструирования

Запись в журнал с упреждением – это еще один пример применения инкапсуляции и выделения слоя, причем даже в случае, когда эти функциональные возможности никогда не будут использоваться в другой части программного обеспечения: в конце концов, в каком количестве программ задумываются о номерах LSN в кэше? Несмотря на это, дисциплина полезна и облегчает поддержку программного обеспечение, его тестирование, отладку и расширение.

4.7. Менеджер блокировок: Lock

Точно также, как и Mpool, менеджер блокировок был разработан как компонент общего назначения: иерархический менеджер блокировок (смотрите [GLPT76]) создан для поддержки иерархии объектов, которые могут быть заблокированы (например, отдельные элементы данных) – страница, на которой размещен элемент данных, файл, в котором запомнен элемент данных или даже набор файлов. Поскольку мы описываем возможности менеджера блокировок, мы также объясним, как они используются в Berkeley DB. Но, как и с Mpool, важно запомнить, что другие приложения могут использовать менеджер блокировок совершенно по-другому и это нормально - он был разработан максимально гибко и поддерживает множество различных вариантов применений.

В менеджере блокировок есть три ключевые абстракции: «блокировка» («locker»), которая определяет, на действия какого объекта устанавливается блокировка, «объект блокировки» («lock_object»), который определяет блокируемый элемент, и «матрица конфликтов» («conflict matrix»).

Блокировки являются 32- разрядными целыми числами. Berkeley DB делит пространство имен 32-разрядных чисел на транзакционные и на нетранзакционные блокировки (хотя это различие является прозрачным для менеджера блокировок). Когда Berkeley DB использует менеджер блокировок, он назначает идентификаторы ID блокировок в диапазоне от 0 до 0x7fffffff для нетранзакционных блокировок и в диапазоне от 0x80000000 и до 0xffffffff — для транзакционных блокировок. Например, когда приложение открывает базу данных, Berkeley DB для того, чтобы обеспечить, чтобы никакой другой поток управления не удалил ее или не переименовал, пока она используется, устанавливает для этой базы долговременную блокировку чтения. Поскольку это долговременная блокировка, она не принадлежит какой-либо транзакции и объект "блокировка", осуществляющий эту блокировку, является нетранзакционным.

Необходимо, чтобы любое приложение, использующее менеджер блокировок, назначало идентификаторы блокировок, поэтому в интерфейсе API менеджера блокировок есть вызовы DB_ENV->lock_id и DB_ENV->lock_id_free, предназначенные для выделения и освобождения блокировок. Таким образом, приложениям не нужно реализовывать свои собственные механизмы выделения блокировок, хотя они, конечно, могут это делать.

4.7.1. Блокируемые объекты

Блокируемые объекты являются строками байтов произвольной длины и с любой внутренней структурой, представляющие блокируемые объекты. Когда две различных блокировки хотят заблокировать некоторый конкретный объект, они для ссылки на этот объект пользуются одной и той же строкой байтов. То есть, обязанность самих приложений соблюдать конвенции, касающиеся внутренней структуры блокируемых объектов.

Например, Berkeley DB использует структуру DB_LOCK_ILOCK для описания блокировок баз данных. В этой структуре есть три поля: идентификатор файла, номер страницы и тип.

Почти во всех случаях, Berkeley DB нужно описывать только конкретный файл и страницу, которую она хочет заблокировать. Berkeley DB назначает уникальный 32-разрядный номер каждой базе данных во время ее создания, записывает его в страницу метаданных базы данных, а затем использует его в качестве уникального идентификатора базы данных в подсистеме Mpool , подсистеме блокировок и журнальной подсистеме. Это идентификатор fileid, к которому мы ссылаемся в структуре DB_LOCK_ILOCK. Естественно, что номер страницы указывает, какую страницу из определенной базы данных мы хотим заблокировать. Когда мы обращаемся к блокировке страниц, мы задаем значение в поле типа в структуре DB_PAGE_LOCK. Но мы можем при необходимости блокировать также другие типы объектов. Как уже упоминалось ранее, мы иногда блокируем дескриптор базы данных, для которого нужно указать тип DB_HANDLE_LOCK. Тип DB_RECORD_LOCK позволяет нам в методе доступа к очереди выполнить блокировку уровня записи, а тип DB_DATABASE_LOCK позволяет нам блокировать всю базу данных.

Девятый урок конструирования

Решение в Berkeley DB использовать блокировку на уровне страниц было сделано по уважительным причинам, но мы обнаружили, что иногда из-за этого решения возникают проблемы. Блокировка на уровне страниц ограничивает возможность распараллеливания приложений, поскольку один поток, модифицирующий запись на странице базы данных, будет мешать другим потокам управления модифицировать другие записи на той же самой странице, тогда как при блокировке на уровне записей такое распараллеливание допускается до тех пор, пока два потока управления не будут модифицировать одну и ту же запись. Блокировка на уровне страниц повышает стабильность базы данных, поскольку она ограничивает количество возможных путей восстановления (во время восстановления страница всегда находится в одном из двух состояний, в отличие от бесконечного числа возможных состояний, в которых страница может быть в случае, если на странице добавляется или удаляется несколько записей). Поскольку Berkeley DB предназначалась для использования в качестве встроенной системы, когда нет администратора базы данных, который мог бы исправить ситуацию в случае возникновения повреждения, мы отдали предпочтение стабильности, а не возможности увеличения распараллеливания.

4.7.2. Матрица конфликтов

Последней абстракцией подсистемы блокировок, которую мы обсудим, является матрица конфликтов. В матрице конфликтов определяются различные типы блокировок, присутствующие в системе и их взаимодействие между собой. Давайте назовем сущность, удерживающую блокировку, владельцем блокировки, а сущность, пытающуюся выполнить блокировку, инициатором блокировки, и давайте считать, что владелец и инициатор имеют разные идентификаторы блокировок. Матрица конфликтов представляет собой массив, индексируемый с помощью пары [инициатор] [владелец], где каждый элемент содержит ноль в случае, если конфликта нет, что указывает, что запрашиваемая блокировка может быть выполнена, и содержит единицу в случае, если имеется конфликт, что указывает, что запрос не может быть выполнен.

В менеджере блокировок имеется матрица конфликтов, используемая по умолчанию, в которой указано именно то, что требуется для Berkeley DB, однако, приложение может использовать свои собственные режимы блокировок и матрицу конфликтов, соответствующие собственным целям приложения. Единственное требование к матрице конфликтов – чтобы она была квадратная (в ней одинаковое количество строк и столбцов) и чтобы в приложении для описания его собственных режимов блокировок (например, чтения, записи и т. д.) использовались последовательно идущие целые числа, начинающиеся с нуля. В таблице 4.2 показана матрица конфликтов для Berkeley DB.

Владелец
Инициатор No-Lock Read Write Wait iWrite iRead iRW uRead wasWrite
No-Lock
Read
Write
Wait
iWrite
iRead
iRW
uRead
iwasWrite

Таблица 4.2: Матрица конфликтов чтения-записи

4.7.3. Поддержка иерархической блокировки

Прежде чем объяснять различные режимы блокировок, указываемые в матрице конфликтов Berkeley DB, давайте поговорим о том, как в подсистеме блокировок поддерживаются иерархические блокировки. Иерархическая блокировки представляют собой возможность блокировать различные элементы в иерархии вложения. Например, файлы содержат страницы, а страницы содержат отдельные элементы. Когда в иерархической системе блокировок изменяется один элемент на странице, нам нужно блокировать только этот элемент; если мы изменили все элементы страницы, было бы более эффективным просто заблокировать страницу, и если мы изменили все страницы файла, было бы лучше заблокировать весь файл. Кроме того, иерархическая блокировка должна различать иерархию контейнеров, поскольку блокировка страницы также говорит о блокировке файлов: вы не можете изменить файл, содержащий страницу, в тот же самый момент, когда модифицируются страницы файла.

Тогда вопрос состоит в том, как разрешать различным блокировщикам выполнять блокировку на различных иерархических уровнях, но при этом не получить в результате хаос. Ответ кроется в конструкции, которая называется уведомлением о блокировке (intention lock). Блокировщик создает внутри контейнера уведомление о блокировке с тем, чтобы сообщить о намерении заблокировать объекты внутри этого контейнера. Поэтому получение блокировки чтения на странице подразумевает получение уведомления о блокировке файла. Аналогично, чтобы записать единственный элемент страницы, вы должны создать уведомление о блокировке записи, как для страницы, так и для файла. В матрице конфликтов, приведенной выше, блокировки iRead, iWrite и iWR являются уведомлениями о блокировках, которые указывают на намерение выполнить чтение, запись или и то и другое, соответственно.

Поэтому когда выполняется иерархическая блокировка, а не запрос на отдельную блокировку, необходимо запрашивать потенциально много блокировок: блокировку на фактически блокируемый объект, а также уведомления о блокировках на любой объект, в котором содержится блокируемый объект. В результате в Berkeley DB нужно обращаться к интерфейсу DB_ENV->lock_vec, который получает массив запросов на блокировку и атомарно реализует их (или отклоняет).

Хотя внутри самой Berkeley DB иерархическая блокировка не используется, она дает преимущество за счет возможности указывать различные матрицы конфликтов и возможности указывать за один раз сразу несколько запросов. Когда поддерживаются транзакции, мы используем матрицу конфликтов, предлагаемую по умолчанию, но с помощью другой матрицы конфликтов можно реализовать простой одновременный доступ без поддержки транзакций и восстановления. Чтобы подключить блокировки, мы пользуемся DB_ENV->lock_vec, методикой, которая улучшает распараллеливание при обходе дерева Btree [Com79]. Когда вы подключаете блокировку, вы ее сохраняете только в течение того времени, которое необходимо для получения следующей блокировки. То есть, вы блокируете внутреннюю страницу Btree только в течение того времени, которое нужно для чтения информации, позволяющей вам выбрать и заблокировать страницу на следующем уровне.

Десятый урок конструирования

Универсальная архитектура Berkeley DB принесла хорошие плоды, когда мы добавили функции параллельной работы с хранилищами данных. Первоначально в Berkeley DB предлагалось только два режима: либо вы, когда записывали данные, работали без какого-либо распараллеливания, либо - с полной поддержкой транзакций. Поддержка транзакций влечет за собой определенные сложности для разработчиков, и мы выяснили, что для некоторых приложений требовалась большая степень распараллеливания без накладных расходов, связанных с полной поддержкой транзакций. Чтобы реализовать эту возможность, мы добавили поддержку блокировок на уровне API, что позволяет пользоваться распараллеливанием при одновременной гарантии отсутствия тупиковых ситуаций. При использовании курсоров для этого потребовался новый и отличающийся режим блокировки. Вместо того, чтобы добавлять специальный код в менеджер блокировок, мы смогли создать альтернативную матрицу блокировок, в которой поддерживаются только режимы блокировок, необходимые для блокировки на уровне API. Таким образом, мы смогли получить необходимые нам режимы блокировок просто при помощи задания другой конфигурации менеджера блокировок. (К сожалению, изменить методы доступа оказалось не так легко; еще есть большие куски кода в методах доступа, в которых осуществляется обработка этого специального режима параллельного доступа).

4.8. Менеджер журнала: Log

Менеджер журнала предоставляет абстрактную модель структурированного файла, позволяющего только добавлять записи. Как и в других модулях, мы намеревались разработать универсальные средства записи в журнал, впрочем, подсистема ведения журнала, вероятно, является, как минимум, тем модулем, где мы добились успеха.

Одиннадцатый урок конструирования

Когда вы обнаруживаете проблемы в архитектуре и не хотите исправлять их «прямо сейчас», а склонны просто их пропустить, помните, что закусанные утками, вы умрете точно также, как если бы вас затоптали слоны. Для улучшения структуру программы без колебаний целиком меняйте целые фреймворки, а когда делаете изменения, не делайте частичные изменения в надежде на то, что позже вы приведете все в порядок — делайте все, а затем двигайтесь дальше. Часто повторяют: "если у вас нет времени сделать это прямо сейчас, у вас не найдется времени сделать это позже". И когда вы меняете фреймворки, пишите схемы тестирования.

Концептуально журнал сравнительно прост: он получает байтовые строки произвольной структуры и записывает их последовательно в файл, присваивая каждому уникальный идентификатор, называемый порядковым номером в журнале (log sequence number - LSN). Кроме того, журнал должен обеспечивать эффективный обход в прямом и обратном направлении и поиск по LSN. Есть два сложных момента: во-первых, журнал должен гарантировать, что он не будет испорчен после любого возможного отказа (что означает, что он содержит непрерывную последовательность неповрежденный журнальных записей), во-вторых, поскольку при подтверждении транзакций журнальные записи должны записываться в постоянное хранилище, производительность журнала является ,как правило, является именно тем, что ограничивает производительность любого приложения, использующего транзакции.

Поскольку журнал является структурой, в которую можно только добавлять записи, он может расти неограниченно. Мы реализуем журнал как совокупность последовательно пронумерованных файлов, так что место для журнала могут восстановить простым удалением старых журнальных файлов. Учитывая, что для журнала используется многофайловое решение, мы формируем номера LSN в виде пары, указывающей номер файла и смещение в файле. Таким образом, для заданного номера LSN журнальный менеджер тривиальным образом находит запись: он обращается по указанному смещению в заданном файле и возвращает запись, находящуюся в этом месте. Но как журнальный менеджер знает, сколько байтов вернуть из этого места?

4.8.1. Формат журнальных записей

Для того, чтобы для заданного номера LSN журнальный менеджер мог определить размер записи, которую он возвращает, в журнале с каждой записью должны храниться метаданные. Как минимум, нужно знать длину записи. Мы предваряем каждую журнальную запись заголовком записи, в котором указывается длина записи, смещение от предыдущей записи (для облегчения обратного обхода) и контрольная сумма журнальной записи (для идентификации разрушения журнала и определения конца журнального файла). Этих метаданных журнальному менеджеру достаточно для поддержки последовательно записываемых журнальных записей, но недостаточно для того, чтобы выполнить действительное восстановление; эти функциональные возможности закодированы в содержимом журнальных записей и определены тем, как Berkeley DB использует эти журнальные записи.

Berkeley DB использует журнальный менеджер для того, чтобы записывать образы данных перед обновлением элементов данных в базе данных и после их обновления [HR83]. В этих журнальных записях достаточно информации для того, чтобы либо повторить (действие redo), либо отменить (действие undo) операцию в базе данных. Berkeley DB использует журнал для отмены транзакции (то есть, отменяя все результаты выполнения транзакции при отмене транзакции) и для восстановления после сбоя приложения или системы.

В добавок, к интерфейсам API, предназначенным для чтения и сохранения журнальных записей, в журнальном менеджере предоставляется интерфейс API, позволяющий принудительно выгружать журнальные записи на диск (DB_ENV->log_flush). Это позволяет в Berkeley DB реализовать упреждающую запись в журнал – прежде, чем некоторая страница будет удалена из Mpool, Berkeley DB проверит номер LSN на странице и попросит журнальный менеджер обеспечить, чтобы указанный номер LSN был в постоянном хранилище. Только после этого Mpool записывает страницу на диск.

Двенадцатый урок конструирования

В Mpool и Log для того, чтобы упростить упреждающую запись в журнал, используются внутренние методы-обработчики и в некоторых ситуациях объявление метода по размеру больше, чем исполняемый код, поскольку код чаще всего лишь сравнивает два целых значения и больше ничего не делает. Зачем утруждать себя такими незначительными методами, только для возможности выделения слоев? Потому, что если ваш код не настолько объектно-ориентированный, чтобы вызывать зубную боль, то он не является достаточно объектно-ориентированным. В каждом куске кода нужно делать небольшую часть работы и должен быть более высокий уровень, на котором программистам будет предложено создавать функции из меньших фрагментов, и так далее. Если есть что-нибудь, что мы узнали о разработке программ в последние несколько десятилетий, это то, что наши возможности создавать и поддерживать значительные фрагменты программ достаточно хрупки. Создание и поддержание значительной фрагментов программного обеспечения является сложным процессом, подверженным появлению ошибок, и, как архитектор программы, вы должны сделать все, что можно, так рано, настолько это можно, и делать настолько часто, насколько это возможно, чтобы максимизировать информацию, отображаемую в структуре вашей программы.

В Berkeley DB для того, чтобы облегчить восстановление, в журнальных записях задается определенная структура. Большинство журнальных записей Berkeley DB описывают транзакционные модификации. Таким образом, большинство журнальных записей отражают модификации страниц в базе данных, выполняемых в рамках транзакций. Исходя из этого можно это описание можно взять за основу для определения того, что должно указываться в метаданных Berkeley DB, добавляемых к каждой журнальной записи: база данных, транзакция и тип записи. Поля идентификатора транзакции и типа записи находятся в каждой записи на одном и том же месте. Это позволяет системе восстановления извлекать тип записи и перенаправлять запись в соответствующий обработчик, который может интерпретировать запись и выполнить соответствующие действия. Идентификатор транзакции позволяет процессу восстановления идентифицировать транзакцию, которой принадлежит журнальная запись, с тем чтобы на любой стадии восстановления было известно, можно ли эту запись проигнорировать или ее нужно обработать.

4.8.2. Разрушая абстракцию

Есть также несколько «специальных» журнальных записей. Среди таких специальных записей записи о контрольных точках являются, пожалуй, наиболее известными. Процесс создания контрольных точек является процессом запоминания на диске состояния базы данных в определенные моменты времени. Другими словами, Berkeley DB для улучшения производительности агрессивно кэширует в Mpool страницы баз данных. Но эти страницы должны быть, в конечном счете, записаны на диск, и чем скорее мы это сделаем, тем быстрее мы сможем сделать восстановление в случае отказа приложения или системы. При этом возможен компромисс между частотой создания контрольных точек и продолжительностью восстановления: чем чаще система создает контрольные точки, тем более быстро ее можно будет восстановить. Создание контрольных точек является транзакционной функцией, поэтому мы рассмотрим детали создания контрольных точек в следующем разделе. В данном разделе, в соответствие с его спецификой, мы рассмотрим записи о контрольных точках и о том, как журнальный менеджер выступает в двух ролях – как автономно работающий модуль и как компонент Berkeley DB специального назначения.

Обычно сам журнальный менеджер понятия не имеет о типах записей, так что теоретически он не должен отличать записи о контрольных точках от других записей – они просто являются строками байтов с произвольной структурой, которые журнальный менеджер записывает на диск. На практике, в журнале хранятся метаданные, а это указывает, что журнальный менеджер понимает содержание некоторых записей. Например, во время запуска журнала, журнальный менеджер проверяет все файлы, которые он может найти, и определяет, какой из них был записан последним. Он предполагает, что все журнальные файлы, записанные до этого, заполнены и он их не трогает, а начинает исследовать самый последний журнальный файл и пытается определить, сколько в нем содержится действительных журнальных записей. Он читает журнальный файл с начала и останавливается в случае, если / когда он обнаруживает заголовок журнальной записи, в котором находится неверная контрольная сумма, что указывает на конец журнала или начало испорченной части журнального файла. В любом случае это логический конец журнала.

В процессе этого чтения журнала, когда происходит поиск текущего конца журнала, журнальный менеджер извлекает тип записи Berkeley DB и ищет записи о контрольных точках. Он запоминает позицию последней найденной им записи о контрольной точке в метаданных журнального менеджера в качестве «предпочтительной» для системы транзакций. Т.е. последнюю контрольную точку должна искать система транзакций, но вместо того, чтобы позволить как журнальному менеджеру, так и менеджеру транзакций обоим прочитывать весь журнальный файл, менеджер транзакций делегирует эту задачу журнальному менеджеру. Это классический пример нарушения границ абстрагирования в обмен на повышение производительности.

Каковы последствия этого компромисса? Представьте, что система, отличная от Berkeley DB, использует журнальный менеджер. Если случится так, что в той же самой позиции, где Berkeley DB размещает свой тип записи, будет записано значение, соответствующее типу записи о контрольной точке, то журнальный менеджер идентифицирует эту запись, как запись о контрольной точке. Однако, если приложение запросит у журнального менеджера эту информацию (прямым доступом к полю cached_ckp_lsn в метаданных журнала), эта информация никак ни на что не повлияет. Короче говоря, это либо вредное нарушение деления проекта на слои, либо хитроумная оптимизация производительности.

Управление файлами является еще одним местом, где разделение между журнальным менеджером и Berkeley DB является нечетким. Как уже упоминалось ранее, в большинстве журнальных записей Berkeley DB должна указываться база данных. В каждой журнальной записи должно быть полное имя файла базы данных, но это дорого с точки зрения расходования пространства журнала и громоздко, поскольку процесс восстановления должен использовать отображение этого имени в дескриптор определенного вида, который можно использовать для доступа к базе данных (дескриптор файла или дескриптор базы данных). Вместо этого, база данных в Berkeley DB идентифицируется в журнале по целочисленному идентификатору, называемому идентификатором файла и реализован набор функций, называемый dbreg (для «регистрации базы данных»), который поддерживает соответствие между именами файлов и идентификаторами журнальных файлов. Вариант такого отображения, хранящийся на диске (имеющий тип записи DBREG_REGISTER), заносится в журнальные записи при открытии базы данных. Тем не менее, для того, чтобы облегчить отмену транзакций и выполнения восстановления, нам также нужны варианты представлениях этого отображения в оперативной памяти. На какую из подсистем возложить обязанность поддержки этого отображения?

Теоретически, отображение файла в идентификатор журнального файла является высокоуровневой функцией Berkeley DB; она не принадлежит ни к одной из подсистем, и она не вписывается в общую картину. В исходном варианте проекта информация об отображении оставалась в структурах данных журнальных подсистем, поскольку казалось, что журнальная система является лучшим вариантом. Тем не менее, после повторного поиска и исправления ошибок в реализации, поддержка отображения была изъята из кода журнальной подсистемы и была преобразована в свою собственную небольшую подсистему со своим собственным объектно-ориентированным интерфейсом и собственными структурами данных. (В ретроспективе, эта информация должна логически размещаться в самой информационной среде Berkeley DB вне любой подсистемы).

Тринадцатый урок конструирования

Редко встречается такая вещь, как несущественная ошибка. Конечно, то и дело встречаются опечатки, но ошибка обычно означает, кто-то не понял в полной мере, что он должен сделать и что-то реализовал неправильно. Когда вы исправили ошибку, не ищите симптом: ищите причину, если хотите – непонимание, т.к. это приведет к лучшему пониманию структуры проекта, а также к выявлению фундаментальных недостатков в самом проекте.

4.9. Менеджер транзакций: Txn

Нашим последним модулем является менеджер транзакций, который связывает вместе отдельные компоненты и реализует транзакционные свойства ACID — атомарность (atomicity), целостность (consistency), изолированность (isolation) и долговечность (durability). Менеджер транзакций отвественен за начало и за завершение транзакций (транзакция либо выполняется, либо отменяется), за координацию с журнальным менеджером и с менеджером буферирования при создании контрольных точек транзакций и за общее управление процессом восстановления. Мы по порядку рассмотрим каждую из этих тем.

Джим Грей придумал сокращение ACID для описания ключевых свойств, предоставляемых транзакциями [Gra81]. Atomicity или атомарность означает, что все операции, выполняемые в рамках транзакции, осуществляются в базе данных как единое целое – либо они все выполняются в базе данных, либо они все не выполняются. Consistency или согласованность означает, что транзакция переводит базу данных из одного логически согласованного состояния в другое согласованное состояние. Например, если в приложении указывается, что все сотрудники должны быть назначены в отдел, который описан в базе данных, то это реализуется с помощью свойства согласованности (при правильно написанных транзакциях). Isolation или изолированность означает, что если рассматривать транзакцию, то она выполняется как бы последовательно, а не параллельно с какими-нибудь другими транзакциями. Наконец, durability или долговечность означает, что если транзакция выполнена, то она остается выполненной — никакой сбой не сможет отменить выполненную транзакцию.

Подсистема транзакций с помощью других систем осуществляет реализацию свойств ACID. В ней используются традиционные транзакционные операции begin (начало транзакции), commit (подтверждение выполнения транзакции) и abort (отмена выполнения транзакции), указывающие начальную и конечную точки транзакции. В ней также реализуется вызов вида prepare call, который помогает осуществлять двухфазное подтверждение выполнения транзакций — технологии реализации транзакционных свойств для распределенных транзакций, которые в данной главе не рассматриваются. Транзакционная операция begin создает идентификатор новой транзакции и возвращает в приложение дескриптор транзакции DB_TXN. Транзакционная операция commit создает журнальную запись commit, а затем инициирует запись журнала на диск (если в приложении не указано, что оно готово оказаться от свойства долговечности в обмен на более быструю обработку операции commit) , что гарантирует, что даже при наличии отказа транзакция будет выполнена. Транзакционная операция abort выполняет чтение в обратном направлении журнальных записей, принадлежащих указанной транзакции, отменяя каждую операцию, которая была сделана транзакцией и возвращает базу данных в состояние, предшествующее выполнению транзакции.

4.9.1. Обработка контрольных точек

На менеджер транзакций также возлагается задача создания контрольных точек. В литературе описан ряд различных методик создания контрольных точек [HR83]. В Berkeley DB используется вариант нечетких контрольных точек. По большому счету при создании контрольных точек необходимо записывать на диск состояние буферов в Mpool. Это потенциально дорогостоящая операция, и для того, чтобы избежать длительных отказов в обслуживании, важно, чтобы система, когда она это выполняет, продолжала обрабатывать новые транзакции. Перед тем, как создать контрольную точку, Berkeley DB сначала просматривает множество активных в данный момент транзакций и ищет наименьший номер LSN, записанный какой либо из этих транзакций. Этот номер LSN становится номером контрольной точки LSN. Затем менеджер транзакций просит Mpool сбросить на диск все буферы, в которых были сделаны изменения; запись этих буферов может, в свою очередь, вызвать выполнение операций записи на диск журнала. После того, как все буферы будут надежно сохранены на диске, менеджер транзакций затем записывает в журнал запись о контрольной точке, в которой указывается номер LSN. Эта запись указывает, что результаты всех операций, записанных в журнальных записях перед контрольной точкой с номером LSN, надежно сохранены на диске. Поэтому журнальные записи, находящиеся перед записью о контрольной точке с номером LSN, больше не требуются для восстановления. Это подразумевает следующее: во-первых, система может повторно использовать место в журнальных файлах, находящихся перед записью о контрольной точке LSN. Во-вторых, при восстановлении нужно обрабатывать только записи, находящиеся после контрольной точки LSN, поскольку обновления, описываемые в записях перед контрольной точкой LSN, уже отражены в состоянии на диске.

Обратите внимание, что между записью о контрольной точке с номером LSN и записью о фактической контрольной точки может быть много журнальных записей. Это нормально, поскольку в этих записях описываются операции, которые логически произошло после создания контрольной точки и в случае, если произойдет отказ системы, их, возможно, потребуется восстановить.

4.9.2. Восстановление

Последней частью транзакционной головоломки является процесс восстановления. Цель восстановления — перевести базу данных, хранящуюся на диске, из потенциально несогласованного состояния в согласованное состояние. В Berkeley DB используется довольно обычная двухпроходная схема, которая, в общих чертах, «связана с последней контрольной точкой с номером LSN, отменой всех транзакций, которые никогда не были подтверждены, и повторного выполнения транзакций, которые были подтверждены». Детали выглядят несколько сложнее.

Для того, чтобы Berkeley DB могла повторить или отменить операции, выполненные в базах данных, необходимо реконструировать отображение между идентификаторами журнальных файлов и фактическими базами данных. В журнале хранится вся история записей DBREG_REGISTER, но поскольку базы данных остаются открытыми в течение длительного времени и мы не хотим требовать, чтобы журнальные файлы не изменялась в течение того времени, пока база данных открыта, нам, вероятно, нужен более действенный способ доступа к этому отображению. Прежде, чем делать запись о контрольной точке, менеджер транзакций записывает серию записей DBREG_REGISTER, оисывающих текущее отображение между идентификаторами журнальных файлов и базами данных. Во время восстановления Berkeley DB использует эти журнальные записи для восстановления отображения файлов.

Когда начинается процесс восстановления, менеджер транзакций для того, чтобы определить в журнале место последней записи о контрольной точке, берет из журнального менеджера значение cached_ckp_lsn. В этой записи есть номер контрольной точки LSN. Berkeley DB должна осуществлять восстановление от этой контрольной точки LSN, но для того, чтобы это сделать, она должна реконструировать отображение идентификаторов журнальных файлов, которое было в момент создания контрольной точки LSN; эта информация есть для контрольной точки, находящейся перед контрольной точкой LSN. Таким образом, Berkeley DB должна искать последнюю запись о контрольной точке, которая находится перед контрольной точкой с номером LSN. Чтобы облегчить этот процесс, в записях о контрольных точках есть не только номер контрольной точки LSN, но и номер LSN предыдущей контрольной точки. Процесс восстановление начинается с последней контрольной точки и происходит в обратном направлении по записям о контрольных точках до тех пор, пока не будет найдена запись о контрольной точке, находящаяся перед записью о контрольной точке с номером LSN. Переход от одной записи к другой происходит с использованием поля prev_lsn, которое есть в каждой записи о контрольной точке. Алгоритм следующий:

ckp_record = read (cached_ckp_lsn)
ckp_lsn = ckp_record.checkpoint_lsn
cur_lsn = ckp_record.my_lsn
while (cur_lsn > ckp_lsn) {
    ckp_record = read (ckp_record.prev_ckp)
    cur_lsn = ckp_record.my_lsn
}

Чтобы реконструировать отображения идентификаторов журнальных файлов, процесс восстановления выполняет последовательное чтение, которое начинается с контрольной точки, выбранной в предыдущем алгоритме, до конца журнала. Когда будет достигнут конец файла, восстановленные отображения будут точно соответствовать отображениям, которые были в момент остановки системы. Также во время этого прохода отслеживаются все встретившиеся записи с операцией подтверждения транзакций commit и записываются их идентификаторы транзакций. Любая транзакция, для которой есть журнальные записи, но идентификатор транзакций отсутствует в записи операции подтверждения commit, либо была отменена, либо никогда не завершалась и ее следует трактовать как отмененную. Когда процесс восстановления достигнет конца журнала, направление движения изменится на обратное, и журнал будет читаться в обратном направлении. Для того, чтобы определить нужно ли для встретившейся журнальной записи о транзакции делать отмену операции, из каждой такой записи будет взят идентификатор транзакции, который будет сверен со списком транзакций, выполнение которых было подтверждено, Если процесс восстановления определит, что этот идентификатор транзакций отсутствует в списке подтвержденных транзакций, он определит тип записи и вызовет для этой журнальной записи процедуру восстановления, указывая ей отменить описанную операцию. Если этот идентификатор транзакций есть в списке подтвержденных транзакций, то процесс восстановления будет игнорировать ее на обратном проходе. Этот обратный проход будет продолжаться до контрольно точки с номером LSN [1]. Наконец, процесс восстановления прочитает журнал в последний раз в прямом направлении, на этот раз повторно выполняя действия для каждой журнальной записи, принадлежащей к подтвержденным транзакциям. Когда этот финальный проход будет завершен, процесс восстановления создаст контрольную точку. В этой точке база данных будет полностью согласованной и будет готова к началу работы приложения.

Таким образом, процесс восстановления можно резюмировать следующим образом:

  1. Находим среди недавних контрольных точек контрольную точку, которая предшествует контрольной точке с номером LSN.
  2. Читаем журнал в прямом направлении для того, чтобы восстановить отображения идентификаторов журнальных файлов, и создаем список завершенных транзакций.
  3. Читаем обратно до контрольной точки с номером LSN, отменяя все операции для незавершенных транзакций.
  4. Читаем в прямом направлении, повторно выполняя все операции для подтвержденных транзакций.
  5. Создаем контрольную точку.

Теоретически заключительная контрольная точка не нужна. На практике она ограничивает время при будущих восстановлениях и позволяет оставаться базе данных в согласованном состоянии.

Четырнадцатый урок конструирования

Процесс восстановление базы данных является сложной темой, его трудно написать и еще труднее отладить, поскольку он не должен происходить часто. В своей лекции при получении премии Тьюринга, Эдсгер Дейкстра (Edsger Dijkstra) утверждал, что программирование является трудным по своей сути и нужно признать, что мы не в состоянии справиться с этой задачей. Наша цель, как архитекторов и программистов, заключается в использовании имеющихся в нашем распоряжении инструментов: проектирования, декомпозиции проблем, просмотров, тестирования, конвенции об именах и стилях и других хороших приемов, сводящих проблемы программирования к задачам, которые мы можем решить.

4.10. Заключение

В настоящее время проекту Berkeley DB более двадцати лет. Это, возможно, было первое транзакционное хранилище типа "ключ/значение" и он является прародителем движения NoSQL. Система Berkeley DB продолжает использоваться в качестве основной системы хранения в сотнях коммерческих продуктов и тысячах приложений с открытым кодом (включая движки SQL, XML и NoSQL) и установлена на миллионах компьютерах по всему миру. Уроки, которые мы получили в ходе ее развития и сопровождения, отражены в ее коде и обобщены в советах по конструированию, изложенных выше. Мы предлагаем их в надежде, что другие разработчики программного обеспечения и архитекторы посчитают их полезными.

Примечания

1. Обратите внимание, что нам нужен обратный проход только до контрольной точки с номером LSN, а не до контрольной точки, предшествующей ей.

Creative Commons

Перевод был сделан в соответствие с лицензией Creative Commons. С русским вариантом лицензии можно ознакомиться здесь.

5.1. История создания CMake и предъявленные к нему требования

Когда разрабатывался CMake, обычной практикой было использование в проекте конфигурационного скрипта и файлов Makefile для платформ Unix, а для Windows - проектных файлов Visual Studio. Такая двойственность систем сборки делала кросс-платформенную разработку во многих проектах очень утомительной: простое действие по добавлению в проект нового файла с исходным кодом оказывалось тяжелым делом. Очевидно, что разработчики хотели иметь единую унифицированную систему сборки. У разработчиков CMake был опыт двух подходов к решению проблемы унифицированной системы сборки.

Первым подходом была система сборки VTK разработки 1999 года. Эта система состояла из конфигурационного скрипта для Unix и исполняемого модуля для Windows, называемого pcmaker. Программа pcmaker, которая была написана на языке C, читала файлы Makefile для платформ Unix и создавала файлы NMake - для Windows. Исполняемый модуль pcmaker помещался в репозиторий CVS системы VTK. В некоторых типичных случаях, например, при добавлении новой библиотеки, нужно было изменять исходный код этого модуля и новый двоичный модуль снова помещать в репозиторий. Хотя, в некотором смысле, это была унифицированная система, в ней было много недостатков.

Другой подход, опыт применения которого был у разработчиков, состоял в использовании системы gmake, представляющей собой базовую систему сборки для системы TargetJr. Система TargetJr являлась средой машинного зрения, написанной на языке C++ и первоначально разработанной для рабочих станций фирмы Sun. Сначала в TargetJr при создании файлов Makefile использовалась система imake. Но, в какой-то момент, когда потребовался порт для Windows, была создана система gmake. С этой системой, базирующейся на gmake, можно было использовать как компиляторы для платформы Unix , так и компиляторы для Windows. Системе требовалось несколько переменных среды окружения, которые нужно было установить перед запуском gmake. Неверная настройка среды окружения приводила к сбою в работе системы, причем такому, в котором было трудно разобраться, особенно - конечным пользователям.

Оба эти подхода страдали от серьезного недостатка: они заставляли разработчиков приложений для Windows использовать командную строку. Опытные разработчики предпочитают в Windows использовать интегрированные среды разработки (IDE). Это побуждало разработчиков приложений для Windows вручную создавать файлы IDE и добавлять их в проект, снова создавая двойную систему сборки. Кроме отсутствия поддержки среды IDE, в обоих подходах, описанных выше, было чрезвычайно трудно совмещать вместе различные программные проекты. Например, в VTK было очень мало модулей чтения изображений, главным образом, из-за того, что в системе сборки было очень трудно пользоваться библиотеками, например, libtiff и libjpeg.

Было решено, что новая система сборки должна быть разработана для ITK и, в общем случае, для C++. Основные требования к новой системе сборки выглядели следующим образом:

Чтобы избежать зависимости от каких-либо дополнительных библиотек и анализаторов, CMake был спроектирован только с одной главной зависимостью — компилятором С++ (который, как мы можем с уверенностью предположить, у нас есть, если мы собираем код C++). В то время было трудно собирать и устанавливать скриптовые языки, например, Tcl, на многих популярных системах UNIX и Windows. Сегодня на современных суперкомпьютерах и защищенных компьютерах, у которых нет подключения к интернету, это все еще остается проблемой, поскольку все еще трудно собирать библиотеки сторонних разработчиков. Т.к. система сброки, как таковая, являлась главной задачей, было решено, что в CMake не будут добавляться какие-либо дополнительные зависимости. Из-за этих ограничений в CMake пришлось создать свой собственной простой язык, из-за которого некоторым все еще не нравится CMake. Впрочем, тогда самым популярным встроенным языком был Tcl. Если бы CMake был системой сборки на основе Tcl, то вряд ли бы он достиг той популярности, которой он обладает сегодня.

Возможность создавать проектные файлы IDE является сильной стороной CMake, но это также является ограничением CMake, поскольку поддерживаются только те возможности, которые изначально присутствуют в IDE. Впрочем, преимущества получения сборочных файлов для конкретной IDE перевешивают эти ограничения. Хотя такое решение сделало разработку CMake сложнее, оно существенно облегчило разработку ITK и других проектов, в которых применяется CMake. Разработчикам удобнее и продуктивнее работать с теми инструментальными средствами, с которыми они лучше знакомы. Благодаря тому, что разработчики пользуются инструментальными средствами, которым они отдают предпочтение, в проектах можно наилучшим образом использовать самый важный ресурс - разработчиков.

Всем программам C/C++ необходим один или несколько следующих сборочных блоков: исполняемые модули, статические библиотеки, разделяемые библиотеки и плагины. CMake должен был позволять создавать эти компонентов для всех поддерживаемых платформ. Несмотря на то, что создание таких компонентов поддерживается на всех платформах, флаги компилятора, которые используются при их создании, существенно различаются в зависимости от компиляторов и платформ. За счет того, что в CMake сложность и различие платформ скрыты в простой команде, разработчики могут создавать такие компоненты в Windows, Unix и Mac. Это позволяет разработчикам сосредоточиться на проекте, а не на деталях, связанных со сборкой разделяемой библиотеки.

В системе сборки дополнительная сложность связана с генераторами кода. В VTK с самого начала была предложена схема, в которой код на C++ автоматически помещается внутрь кода Tcl, Python и Java следующим образом: выполняется анализ заголовочных файлов C++ и автоматически создается слой-обертка. Для этого необходима система сборки, которая может собрать исполняемый модуль на C/C++ (генератор обвертки), а затем на этапе сборки запустить этот модуль и создать другой исходный код C/C++ (обертки для конкретных модулей). Затем этот сгенерированный исходный код должен быть откомпилирован в исполняемые модули или разделяемые библиотеки. Все это должно произойти в среде IDE и с использованием сгенерированных файлов Makefile.

Когда на C/C++ разрабатываются универсальные кросс-платформенное программы, важно программировать функциональные возможности системы, а не конкретную систему. В autotools есть модель для проведения самопроверки системы, которая состоит из компиляции небольших фрагментов кода, его проверки и запоминания результатов. Поскольку подразумевалось, что CMake должен был быть кросс-платформенным, в него вошла подобная технология самопроверки системы. Она позволяет разработчикам выполнять программирование для канонической системы, а не для конкретных систем. Это важно для достижения мобильности в будущем, поскольку со временем меняются как компиляторы, так и операционные системы. Например, следующий код:

#ifdef linux
// делаем что-то для linux
#endif

менее устойчив, чем следующий код:

#ifdef ИМЕЕТСЯ_ФУНКЦИЯ
// делаем что-то с помощью этой функции
#endif

Другое первоначальное требование к CMake также было взято из autotools: возможность создавать деревья сборки, которые будут отделены от дерева исходных кодов. Это позволяет для одного и того же дерева исходных кодов создавать несколько типов деревьев сборки. В результате предотвращается влияние деревьев сборки на дерево исходных кодов, что часто мешает работе систем контроля версий.

Одной из наиболее важных особенностей системы сборки является возможность управлять зависимостями. Если исходный файл изменен, то все программы, использующие этот исходный файл, должны быть пересобраны заново. Для кода C/C++ как часть зависимостей должны быть также проверены заголовочные файлы, включенные в файл .c или .cpp. Если не отслеживать случаи, когда в действительности должна компилироваться только часть кода, то из-за неверной информации о зависимостях может затрачиваться большое количество времени.

Все требования к новой системе сборки и ее функциональные возможности должны быть одинаково хорошо реализованы для всех поддерживаемых платформ. Необходимо, чтобы CMake был простым API, позволяющим разработчикам, не знающим особенностей платформы, создавать сложные программные системы. В сущности, программы, использующие CMake, перенаправляют в CMake всю сложность, связанную со сборкой. После того, как было создано общее представление об инструментальном средстве сборки и базовом наборе к нему требований, потребовалось достаточно гибко выполнить реализацию. В проекте ITK система сборки нужна была практически с первого дня. Первые версии CMake не отвечали всему набору требований это общего представления, но могли выполнять сборку на Windows и Unix.

5.2. Как реализован CMake

Как уже упоминалось, языками разработки пакета CMake являются C и C++. Чтобы объяснить его внутренние особенности, далее в настоящем разделе сначала описывается процедура использования CMake в том виде, как ее видит пользователь, а затем изучаются используемые в CMake структуры.

5.2.1. Процедура использования CMake

Процедура использования CMake состоит из двух основных этапов. Во-первых, это шаг "конфигурирования", на котором пакет CMake обрабатывает все переданные ему входные данные и создает внутреннее представление сборки, которую нужно выполнить. Затем идет следующий этап - шаг "генерации". На этом этапе создаются файлы фактической сборки.

Переменные среды окружения (или не среды окружения)

Во многих системах сборки в 1999 году и даже сегодня во время создания проекта используются переменные среды окружения командной оболочки. Закономерно, что в проекте есть переменная среды окружения PROJECT_ROOT, которая указывает на местонахождение корня дерева исходных кодов. Переменные среды окружения также используются для указания на дополнительные или внешние пакеты. Трудность этого этого подхода в том, что для того, чтобы сборка проекта работала, нужно каждый раз, когда выполняется сборка, устанавливать значения всех этих внешних переменных. Чтобы решить эту проблему, в CMake есть кэш-файл, в котором в одном месте запомнены все переменные, необходимые для сборки. Они являются переменными CMake , а не переменными командной оболочки или среды окружения. Когда CMake запускается первый раз для конкретного дерева сборки, он создает файл CMakeCache.txt, в котором постоянно хранятся все переменные, необходимые для этой сборки. Поскольку этот файл является частью дерева сборки, переменные всегда будут доступны для CMake при каждом его запуске.

Шаг конфигурирования

На шаге конфигурирования CMake сначала читает файл CMakeCache.txt, если тот существует после предыдущего запуска. Затем он читает файл CMakeLists.txt, который находится в корне дерева исходных кодов, переданных в CMake. На шаге конфигурирования файл CMakeLists.txt анализируется анализатором языка CMake. Каждая команда CMake, обнаруженная в файле, выполняется объектом с образом команды. Кроме того, на этом шаге с помощью команд CMake include и add_subdirectory может быть проанализирован файл CMakeLists.txt. В CMake для каждой команды, которая может использоваться в языке CMake, есть объект C++. Примерами таких команд являются команды add_library, if, add_executable, add_subdirectory и include. В сущности, весь язык CMake реализован в виде обращений к командам. Синтаксический анализатор просто преобразует входные файлы CMake в вызовы команд и списки строк, которые являются аргументами этих команд.

На шаге конфигурирования, по существу, "работает" код CMake, предоставленный пользователями. После того, как весь код будет выполнен и будут вычислены все значения кэш-переменных, в CMake будет запомненное представление проекта, который должен быть собран. В него будут включены все библиотеки, исполняемые модули, команды пользователя и вся другая информация, необходимая для создания окончательных файлов сборки для выбранного генератора. В этом момент файл CMakeCache.txt сохраняется на диске для использования при будущих запусках CMake.

Запомненное представление проекта является набором целевых задач, определяющих просто то, что нужно собрать, например, библиотеки и исполняемые модули. В CMake также можно указывать пользовательские целевые задачи: пользователи могут определять свои собственные входные и выходные данные, а также предоставлять свои собственные исполняемые модули или скрипты, которые должны работать во время сборки. В CMake каждая целевая задача хранится в объекте cmTarget. Эти объекты хранятся, в свою очередь, в объекте cmMakefile, который, по существу, является местом хранения всех целевых задач, найденных в заданном каталоге дерева исходного кода. Итоговым результатом является дерево объектов cmMakefile, содержащих отображения объектов cmTarget.

Шаг генерации

Как только шаг конфигурирования будет завершен, происходит переход к шагу генерации. Шаг генерации — это этап, когда CMake создает файлы сборки для целевого инструментального средства сборки, выбранного пользователем. В этот момент внутреннее представление целевых задач (создание библиотек, исполняемых модулей, пользовательские целевые задачи) преобразуется либо во входные файлы сборочной инструментальной среды IDE, такой как Visual Studio, либо в набор файлов Makefile, который будут обработан командой make. Внутреннее представление, создаваемое CMake после шага конфигурирования, настолько обобщено, что один и тот же код и те же самые структуры данных могут максимальным образом использоваться различными инструментальными средствами сборки.

Общий вид процедуры использования CMake приведен на рис.5.1.

Рис.5.1: Общий вид процедуры использования CMake

5.2.2. CMake: Код

Объекты CMake

CMake является объектно-ориентированной системой, в которой используется наследование, шаблоны проектирования и инкапсуляция. Основные объекты на C++ и их отношения приведены на рис.5.2.

Рис.5.2: Объекты CMake

Результаты анализа каждого файла CMakeLists.txt хранятся в объекте cmMakefile. Объект cmMakefile не только хранит информацию о каталоге, но и управляет анализом файла CMakeLists.txt. Функция анализа обращается к объекту, который для анализа языка CMake обращается к синтаксическому анализатору на базе lex/yacc. Т.к. синтаксис языка CMake меняется не часто, а lex и yacc не всегда есть в системах, где CMake выполняет сборку, файлы, получаемые на выходе lex и yacc, обрабатываются и вместе со всеми другими файлами, созданными вручную, сохранятся в каталоге Source системы контроля версий.

Другим важным классом в CMake является класс cmCommand. Это базовый класс реализации всех команд языка CMake. В каждом его подклассе не только реализуется команда, но и осуществляется ее документирование. В качестве примера взгляните на методы документирования в классе cmUnsetCommand:

virtual const char* GetTerseDocumentation()
{
    return "Unset a variable, cache variable, or environment variable.";
}

/**
 * More documentation.
 */

virtual const char* GetFullDocumentation()
{
    return
      "  unset( [CACHE])\n"
      "Removes the specified variable causing it to become undefined.  "
      "If CACHE is present then the variable is removed from the cache "
      "instead of the current scope.\n"
      " can be an environment variable such as:\n"
      "  unset(ENV{LD_LIBRARY_PATH})\n"
      "in which case the variable will be removed from the current "
      "environment.";
}

Анализ зависимостей

В CMake есть мощные встроенные средства анализа зависимостей для отдельных файлов исходного кода на языках Fortran, C и C++. Поскольку в интегрированных средах разработки (в IDE) есть свои собственные средства работы с информацией о зависимостях файлов, CMake для этих систем сборок пропускает шаг анализа зависимостей. В случае с IDE CMake создает входной файл, нативный для IDE, и предоставляет самой IDE обрабатывать информацию о зависимостях уровня файлов. Информация о зависимостях уровня целевой задачи преобразуется в формат IDE и добавляется к остальной информации о зависимостях.

Что касается сборок, использующих файлы Makefile, то до сих пор нативная программа make не знает, как автоматически вычислять и сохранять информацию о зависимостях. Для этих сборок CMake автоматически вычисляет информацию о зависимостях для файлов на C, C++ и Fortran. Вычисление информации о зависимостях и поддержание ее в актуальном состоянии автоматически выполняется с помощью CMake. После того, как с помощью CMake проект будет первоначально сконфигурирован, пользователям нужно будет только запускать команду make, а остальную работу сделает CMake.

Хотя пользователям не требуется знать, как CMake выполняет эту работу, может оказаться полезным взглянуть в проекте на файлы с информацией о зависимостях. Для каждой целевой задачи эта информация хранится в следующих четырех файлах - depend.make, flags.make, build.make и DependInfo.cmake. В файле depend.make хранится информация о зависимостях для всех объектных файлов каталога. В файле flags.make хранятся флаги компиляции, используемые с исходными файлами для этой целевой задачи. Если они изменились, то файлы должны быть перекомпилированы. Файл DependInfo.cmake все еще используется для хранения информации о зависимостях и также содержит информацию о том, какие файлы являются частями проекта и какие в них используются языки. Наконец, правила для сборки зависимостей хранятся в файле build.make. Если зависимости для целевой задачи устарели, информация о зависимостях для данной целевой задачи будет перерасчитана заново и зависимости будут обновлены. Это делается из-за того, что любое изменение в любом файле .h может добавить новую зависимость.

CTest и CPack

По ходу дела CMake перерос из системы сборки проекта в семейство инструментальных средств, предназначенных для сборки, тестирования и создания пакетов программ. Кроме команды cmake, работающей из командной строки, и программ графического интерфейса CMake, CMake также поставляется в комплекте с инструментальным средством тестирования CTest и средством создания пакетов CPack. CTest и CPack используют тот же самый базовый код, что и CMake, но являются отдельными инструментами и для основной сборки проекта они не нужны.

Модуль ctest предназначен для запуска регрессионных тестов. В проекте можно легко с помощью команды add_test создавать тесты для CTest. Тесты можно выполнить с помощью CTest, а результаты тестирования также можно с помощью CTest отправить в приложение CDash для просмотра их в интернете. CTest и CDash вместе аналогичны инструментальному средству тестирования Hudson. Самое главное их отличие в следующем: CTest поволяет выполнять тестирование в более разнообразных условиях. Клиентские системы можно настроить таким образом, что исходный код будет выбираться из системы контроля версий, затем будут выполняться тесты, а результат будет отправляться в приложение CDash. В случае с Hudson для того, чтобы можно было запускать тесты, на клиентских машинах нужно открывать доступ пакету Hudson по ssh.

Модуль cpack предназначен для создания инсталляторов проектов. Пакет CPack работает почти также, как и та часть CMake, которая выполняет сборку: пакет взаимодействует с другими инструментальными средствами создания пакетов. Например, в Windows для создания в проекте самостоятельно запускаемых инсталляторов используется упаковщик NSIS. CPack запускает на выполнение инсталляционные правила проекта, в результате чего создается инсталляционное дерево, которое затем передается программе, создающей инсталляторы, например, NSIS. CPack также поддерживает создание файлов RPM, файлов .deb — для Debian, а также файлов .tar, .tar.gz и самораспаковывающихся файлов tar.

5.2.3. Графические интерфейсы

Первое, с чем сталкиваются многие пользователи, только что увидевшие CMake, это одна из программ пользовательского интерфейса CMake. В CMake есть два основных приложения пользовательского интерфейса: оконное приложение на базе Qt, а также приложение с текстовым интерфейсом, похожим на графический, работающее из командной строки. Эти графические интерфейсы используются в качестве графического редактора файла CMakeCache.txt. Это сравнительно простые интерфейсы с двумя кнопками - конфигурирование и генерация, используемыми для запуска основных этапов процесса CMake. Текстовый интерфейс доступен на Unix-платформах типа TTY и в Cygwin. Графический интерфейс Qt доступен на всех платформах. Примеры графического интерфейса приведены на рис. 5.3 и 5.4.

Рис.5.3: Интерфейс командной строки

Рис.5.4: Графический интерфейс

В обоих вариантах графического интерфейса имена кэш-переменных размещены слева, а их значения - справа. Значения, указываемые справа, пользователь может заменить теми, которые подходят для конкретной сборки. Есть два набора переменных — обычный и расширенный. По умолчанию пользователю показывается обычный набор переменных. В проекте в файлах CMakeLists.txt можно указать, какие переменные будут входить в расширенный набор переменных. Этот подход позволяет предложить пользователю несколько вариантов, выбираемых для конкретной сборки.

Поскольку по мере того, как выполняются команды, значения кэш-переменных могут изменяться, процесс приближения к финальной сборке может быть итеративным. Например, включение некоторых параметров может потребовать использовать другие параметры. По этой причине в графическом интерфейсе кнопка "генерации" остается отключенной до тех пор, пока у пользователя не будет иметься возможности, по крайней мере, один раз увидеть все параметры. Каждый раз, когда нажимается кнопка конфигурирования, новые кэш-переменные, которые еще не были показаны пользователю, отображаются красным цветом. После того, как во время конфигурирования будут созданы все новые кэш-переменные, будет включена кнопка генерации.

5.2.4. Тестирование CMake

Любой новый разработчик CMake сначала знакомится с процессом тестирования, используемым при разработке CMake. В этом процессе задействовано семейство инструментальных средств CMake (CMake, CTest, CPack и CDash). Как только код разработан и помещен в систему контроля версий, машины, выполняющие тестирование в процессе непрерывной сборки проекта, автоматически выполняют сборку и с помощью пакета CTest тестируют новый код CMake. Результаты отсылаются на сервер CDash, который в случае, если есть ошибки сборки, предупреждения компилятора или если тест прошел неудачно, уведомляет об этом разработчиков по электронной почте.

Это классическая схема тестирования, используемая при непрерывной сборке проекта. Как только новый код помещается в репозиторий CMake, он автоматически тестируется на платформах, поддерживаемых в CMake. Если учесть огромное количество компиляторов и платформ, поддерживаемых в CMake, то такая схема тестирования является весьма важной для разработки стабильной системы сборки.

Например, если новый разработчик захочет добавить поддержку новой платформы, то первый вопрос, который ему будет задан — может ли обеспечить непрерывную работу клиентской программы CMake для этой системы. Без постоянного тестирования новая система через некоторое время неизбежно перестанет работать.

5.3. Усвоенные уроки

CMake с самого первого дня успешно использовался при сборке проекта ITK, и это было самой важной частью проекта. Если бы мы могли снова повторить разработку CMake, то мало что следовало бы изменить. Тем не менее, всегда есть то, что можно было бы сделать лучше.

5.3.1. Обратная совместимость

Поддержка обратной совместимости очень важна для команды разработчиков CMake. Основной целью проекта является сделать проще сборку программ. Когда команда проекта или разработчик выбирают CMake в качестве инструмента сборки, важно уважать этот выбор и нужно очень постараться, чтобы сборку можно было бы продолжать выполнять с помощью последующих релизов CMake. В CMake 2.6 реализована политика, что в случае, если предполагается, что некоторое поведение системы устарело, об этом следует выдавать предупреждение, но в системе само такое устаревшее поведение должно поддерживаться. В каждом файле CMakeLists.txt следует указать, какую предполагается использовать версию CMake. Новые версии CMake могут выдавать предупреждения, но они все равно должны собирать проект так, как это делалось в старых версиях.

5.3.2. Язык, язык, язык

Предполагалось, что язык CMake должен быть очень простым. Тем не менее, он является одним из основных препятствий, когда в новом проекте принимается решение об использовании CMake. С языком CMake в процессе его естественного роста были несколько интересных моментов. Первый синтаксический анализатор языка был создан даже не на базе lex/yacc, а был простым анализатором строк. Когда появился шанс расширить язык, мы потратили некоторое время на поиск хорошего встроенного языка среди уже имеющихся языков. Лучшим вариантом для работы оказался язык Lua. Это очень маленький и ясный язык. Даже если бы не использовался сторонний язык, похожий на Lua, мне надо было с самого начала больше внимания уделять существующему языку.

5.3.3. Плагины не работают

Чтобы в проектах можно было расширять возможности языка CMake, в CMake есть класс плагинов. Он позволяет в проекте на языке C создавать новые команды CMake. На тот момент такой подход казался правильным и для того, чтобы можно было пользоваться другими компиляторами, был определен интерфейс языка C. Но с появлением большого количества систем API, например, для 32/64-разрядных Windows и Linux, совместимость плагинов стало поддерживать трудно. Несмотря на то, что расширение языка CMake с помощью самого CMake является не столь мощным средством, оно позволяет избежать ситуаций, когда CMake перестает работать или не может собрать проект из-за того, что плагин не удалось собрать или загрузить.

5.3.4. Ограничение распространения API

Важный урок, усвоенный в процессе разработки проекта CMake, состоит в том, что вы не должны поддерживать обратную совместимость с тем, к чему пользователи не должны получать доступ. Несколько раз в ходе разработки CMake пользователи и заказчики просили, чтобы CMake был преобразован в библиотеку для того, чтобы функциональные возможности CMake можно было бы добавить к другим языкам. Это бы не только разрушило сообщество пользователей CMake из-за того, что возникло бы множество способов применения CMake, но также чрезмерно увеличило бы затраты на техническое обслуживание проекта CMake.

Примечания

  1. http://www.itk.org/

На главную -> MyLDP -> Тематический каталог ->

Среда разработки Eclipse

Глава 6 из книги "Архитектура приложений с открытым исходным кодом", том 1.

Оригинал: Eclipse, глава из книги "The Architecture of Open Source Applications" том 1.
Автор: Kim Moir
Дата публикации: 2012 г.
Перевод: Н.Ромоданов
Дата перевода: июль 2013 г.

Creative Commons

Перевод был сделан в соответствие с лицензией Creative Commons. С русским вариантом лицензии можно ознакомиться здесь.

Реализация модульности в программах является крайне трудной задачей. Также трудно управлять взаимодействием с большой базой кода, написанного различными представителями сообщества. В проекте Eclipse нам удалось добиться успеха в обоих случаях. В июне 2010 года фонд Eclipse Foundation предоставил свой релиз Helios, скоординированный с более чем 39 проектами и 490 учестниками из более чем 40 компаний, которые работают совместно над разработкой функциональных возможностей базовой платформы. Каков был изначальное архитектурное видение проекта Eclipse? Как он развивался? Как архитектура приложения поощряет участие и повышает роль сообщества в его разработке? Давайте обратимся к истокам.

7 ноября 2001 года была выпущена версия 1.0 проекта с открытым исходным с названием Eclipse. В то время Eclipse описывался как «интегрированная среда разработки (IDE) для всего и для ничего конкретного, в частности». Такое описание было намеренно общим, поскольку с архитектурной точки зрения, это был не просто еще один набором инструментов, это был фреймворк, который был модульным и масштабируемым. Eclipse предоставил платформу, базирующуюся на компонентах, которая могла бы послужить основой для создания инструментария для разработчиков. В этой расширяемой архитектуре сообществу предлагалось опереться на основную платформу и расширять ее за пределы первоначальной концепции. Eclipse стартовал в качестве платформы, а Eclipse SDK был продуктом, предназначенным для проверки концепции. Eclipse SDK был предоставлен разработчикам в их личное распоряжение и одновременно использовался для создания новых версий Eclipse,

Стереотипный образ разработчика открытого исходного кода представляет собой альтруистическую личность, трудящуюся поздней ночью, исправляющую ошибки и реализующую новые фантастические возможности для решения своих частных интересов. Напротив, если вы взгляните на начало истории Eclipse, вы увидите, что часть исходного кода, который был подарен проекту, опирался на проект VisualAge for Java, разработанный IBM. Первые, кто сделал вклад в этот проект с открытым кодом, были сотрудники вспомогательного подразделения IBM, называющегося Object Technology International (OTI). Эти разработчики работали над проектом с открытым исходным кодом полный оплачиваемый рабочий день и отвечали на вопросы из групп новостей, находили ошибки и реализовывали новые возможности. Чтобы расширить эти усилия по созданию подобного открытого инструментария, был сформирован консорциум заинтересованных поставщиков программ. Первоначально членами консорциума Eclipse были Borland, IBM, Merant, QNX Software Systems, Rational Software, RedHat, SuSE и TogetherSoft.

Благодаря инвестициям своих усилий, эти компании получили опыт поставки коммерческих изделий, создаваемых на основе Eclipse. Это похоже на те инвестиции, которые корпорации вкладывают разработку ядра Linux, т. к. в собственных интересах компаний, чтобы сотрудники улучшали программное обеспечение с открытым исходным кодом, лежащем в основе их коммерческих предложений. В начале 2004 года был сформирован фонд Eclipse Foundation, который предназначался для управления и расширения растущего сообщества Eclipse. Этот некоммерческий фонд был профинансирован его корпоративными представителями и управление им осуществлялось советом директоров. Сегодня сообщество Eclipse расширилось еще сильнее и теперь включает более 170 компаний и почти 1000 крупных участников.

Поначалу Eclipse рассматривался только как SDK, но сегодня он представляет из себя намного большее. В июле 2010 года в eclipse.org в стадии разработки было 250 различных проектов. Среди них инструментарий для разработки на языках C/C++ и PHP, для разработки веб-сервисов, разработки на основе моделей, создания инструментальных средств и многое другое. Каждый из этих проектов включен в список проектов верхнего уровня (TLP), которые находятся в ведении Комитета управления проектами (PMC), состоящего из старших членов проекта, взявших на себя ответственность по поддержке технического направления и достижения конкретных целей. Для краткости в этой главе будут рассмотрены только проекты Eclipse SDK из Eclipse [1] и Runtime Equinox [2]. Т.к. Eclipse имеет долгую историю, я сосредоточусь на раннем варианте Eclipse, а также на версиях 3.0, 3.4 и 4.0.

6.1. Ранний вариант Eclipse

В начале 21-го века разработчикам программного обеспечения было предложено много инструментальных средств, но немногими из них можно было пользоваться одновременно. В проекте Eclipse старались создать платформу с открытым кодом, предназначенную для построения совместно работающих инструментальных средств для разработчиков приложений. Это должно было позволить разработчикам сосредоточиться на написании новых инструментов, а не писать код, который будет связан с вопросами инфраструктуры, например, с взаимодействием с файловой системой, работой с обновлениями и с подключением к хранилищам исходного кода. В проекте Eclipse, пожалуй, самым известным инструментальным средством,предназначенным для разработки на языке java, является Java Development Tools (JDT). Предполагалось, что это инструментальное средств для Java будет служить примером для тех, кто заинтересован в разработке инструментария для других языков.

Прежде, чем мы углубимся в архитектуру Eclipse, давайте посмотрим на то, как Eclipse SDK выглядит для разработчика. После запуска Eclipse и выбора рабочего пространства (workbench), вам будет представлен набор панелей для работы с Java (Java perspective). Такой набор состоит из специальных инструментальных панелей и панелей редакторов, которые необходимы в конкретном случае.

Рис.1: Набор панелей для работы с языком Java

В ранних вариантах архитектуры Eclipse SDK было три основных элемента, которые соответствовали трем основным подпроектам: платформе, инструментальным средствам JDT (Java Development Tools) и среде разработки плагинов PDE (Plug-In Development Environment).

6.1.1. Платформа

Платформа Eclipse написана с использованием Java и для того, чтобы ее запустить, требуется виртуальная машина Java VM. Платформа построена из небольших функциональных блоков, называемых плагинами. Плагины являются основой компонентной модели Eclipse. Плагин является, по существу, JAR-файлами с манифестом, в котором описывается плагин, его зависимости, и то, как его можно использовать или расширять. Эта информация манифеста изначально хранилась в файле plug-in.xml, который находился в корневой папке плагина. Инструментальные средства Java представляют собой плагины, предназначенные для ведения разработки на языке Java. Среда разработки плагинов (PDE) предоставляет собой набор инструментов для разработки плагинов расширений Eclipse. Плагины Eclipse написаны на языке Java, но также могут содержать другие добавления, например, файлы HTML в качестве онлайновой документации. Каждый плагин может иметь свой собственный загрузчик классов. Плагины могут зависеть от других плагинов, что указывается в инструкциях requires в файле plugin.xml. Если взгляните на файл plugin.xml плагина org.eclipse.ui, вы увидите его название и номер версии, а также зависимости, которые необходимо импортировать из других плагинов.

<?xml version="1.0" encoding="UTF-8"?>
<plugin
   id="org.eclipse.ui"
   name="%Plugin.name"
   version="2.1.1"
   provider-name="%Plugin.providerName"
   class="org.eclipse.ui.internal.UIPlugin">

   <runtime>
      <library name="ui.jar">
         <export name="*"/>
         <packages prefixes="org.eclipse.ui"/>
      </library>
   </runtime>
   <requires>
      <import plugin="org.apache.xerces"/>
      <import plugin="org.eclipse.core.resources"/>
      <import plugin="org.eclipse.update.core"/>
      :       :        :
      <import plugin="org.eclipse.text" export="true"/>
      <import plugin="org.eclipse.ui.workbench.texteditor" export="true"/>
      <import plugin="org.eclipse.ui.editors" export="true"/>
   </requires>
</plugin>

Чтобы поощрять создание проектов на платформе Eclipse, требуется механизм, который может расширять платформу, и нужно, чтобы платформа могла использовать такое расширение. Это достигается за счет использования расширений и точек расширений, еще одного элемента компонентной модели Eclipse. С помощью экспорта определяются интерфейсы, которыми, как вы ожидаете, воспользуются другие разработчики, когда будут писать собственные расширения; в результате классы, доступные за пределами вашего плагина, ограничиваются только теми, которые экспортируются. Также устанавливаются дополнительные ограничения на ресурсы, доступные за пределами плагина, что представляет собой отличие от того, когда доступными делаются все публичные методы или классы (т. е. типа public — прим.пер.). Экспортируемые плагины рассматриваются как публичный интерфейс API. Все остальное считается частными особенностями реализации (т. е. типа private — прим.пер.). Чтобы написать плагин, который добавит пункт меню на панели инструментов Eclipse, вы можете использовать точку расширения actionSets в плагине org.eclipse.ui.

<extension-point id="actionSets" name="%ExtPoint.actionSets"
                 schema="schema/actionSets.exsd"/>
<extension-point id="commands" name="%ExtPoint.commands"
                 schema="schema/commands.exsd"/>
<extension-point id="contexts" name="%ExtPoint.contexts"
                 schema="schema/contexts.exsd"/>
<extension-point id="decorators" name="%ExtPoint.decorators"
                 schema="schema/decorators.exsd"/>
<extension-point id="dropActions" name="%ExtPoint.dropActions"
                 schema="schema/dropActions.exsd"/>

Расширение вашего плагина, которое добавляет пункт меню к точке расширения org.eclipse.ui.actionSet, будет выглядеть следующим образом:

<?xml version="1.0" encoding="UTF-8"?>
<plugin
   id="com.example.helloworld"
   name="com.example.helloworld"
   version="1.0.0">
   <runtime>
      <library name="helloworld.jar"/>
   </runtime>
   <requires>
      <import plugin="org.eclipse.ui"/>
   </requires>
   <extension
         point="org.eclipse.ui.actionSets">
      <actionSet
            label="Example Action Set"
            visible="true"
            id="org.eclipse.helloworld.actionSet">
         <menu
               label="Example &Menu"
               id="exampleMenu">
            <separator
                  name="exampleGroup">
            </separator>
         </menu>
         <action
               label="&Example Action"
               icon="icons/example.gif"
               tooltip="Hello, Eclipse world"
               class="com.example.helloworld.actions.ExampleAction"
               menubarPath="exampleMenu/exampleGroup"
               toolbarPath="exampleGroup"
               id="org.eclipse.helloworld.actions.ExampleAction">
         </action>
      </actionSet>
   </extension>
</plugin>

Когда запускается Eclipse, среда выполнения платформы сканирует манифесты плагинов вашей инсталляции и строит реестр плагинов, хранящихся в памяти. Отображения между точками расширений и соответствующими расширения осуществляются по именам. Получившийся в результате реестр плагинов можно использовать из интерфейса API, предоставляемого платформой Eclipse. Реестр кэшируется на диске с тем, чтобы эту информацию могла было загрузить снова в следующий раз, когда Eclipse будет перезапущен. Все плагины, которые будут обнаружены при запуске, будут занесены в реестр, но они не будут активированы (загруженные классы) до тех пор, пока код не будет в действительности использован. Этот подход называется ленивой или отложенной активацией. Влияние добавление дополнительных сборок на производительность вашей инсталляции уменьшается за счет того, что действительная загрузка классов, ассоциированных с плагинами, не будет происходить до тех пор, пока классы действительно не понадобятся. Например, плагин, который добавляется к точке расширения org.eclipse.ui.actionSet, не будет активироваться до тех пор, пока пользователь не выберет новый пункт меню на панели инструментов.

Рис.6.2: Пример меню

Код, с помощью которого создается этот пункт меню, выглядит следующим образом:

package com.example.helloworld.actions;

import org.eclipse.jface.action.IAction;
import org.eclipse.jface.viewers.ISelection;
import org.eclipse.ui.IWorkbenchWindow;
import org.eclipse.ui.IWorkbenchWindowActionDelegate;
import org.eclipse.jface.dialogs.MessageDialog;

public class ExampleAction implements IWorkbenchWindowActionDelegate {
    private IWorkbenchWindow window;

    public ExampleAction() {
    }

    public void run(IAction action) {
        MessageDialog.openInformation(
            window.getShell(),
            "org.eclipse.helloworld",
            "Hello, Eclipse architecture world");
    }

    public void selectionChanged(IAction action, ISelection selection) {
    }

    public void dispose() {
    }

    public void init(IWorkbenchWindow window) {
        this.window = window;
    }
}

Как только пользователь выбирает новый элемент на панели инструментов, плагин, реализующий точку расширения, делает запрос в регистр расширений. Плагин, реализующий расширение, создает экземпляр расширения и плагин загружается. Как только будет активирован плагин, в нашем примере будет запущен конструктор ExampleAction, а затем будет инициализировано делегируемое действие Workbench. Поскольку выбор в рабочем пространстве изменился и был создан делегат, действие может поменяться. Откроется диалоговое окно с сообщением «Hello, Eclipse architecture world».

Такая расширяемая архитектура была одним из ключей к успешному росту экосистемы Eclipse. Компании или частные лица могли разрабатывать новые плагины и либо реализовывать их в виде открытого исходного кода или продавать их как коммерческие изделия.

Одна из наиболее важных концепций, касающаяся Eclipse, представляет собой утверждение, что все является плагинами. Независимо от того, входит ли плагин в платформу Eclipse, или вы написали его самостоятельно, все плагины являются главными компонентами собранного приложения. На рис.6.3 показаны кластеры функциональных возможностей, предоставляемых плагинами в ранних вариантах Eclipse.

Рис.6.3: Архитектура ранних вариантов Eclipse

Рабочее пространство (workbench) является наиболее известным элементом интерфейса для пользователей платформы Eclipse, т. к. в нем находятся данные, определяющие, как Eclipse будет представлен пользователю на рабочем столе. Рабочее пространство состоит из наборов окон, отдельных окон и окон редакторов. Редакторы ассоциированы с типами файлов и, поэтому, при открытии файла запускается необходимый редактор. Примером отдельного окна является окно «problems» (Проблемы), в котором указываются ошибки или предупреждения, касающиеся вашего кода на Java. Окна редакторов и отдельные окна совместно образуют набор окон или перспективу (perspective), в котором пользователям предоставляются инструментальные средства, организованные определенным образом.

Рабочее пространство Eclipse строится на базе инструментального набора виджетов Standard Widget Toolkit (SWT) и Jface, причем SWT заслуживает немного более детального рассмотрения. Инструментальные наборы виджетов могут быть как нативными, так и эмулирующими. Нативный инструментальный набор виджетов использует для создания компонентов пользовательского интерфейса, например, списков и кнопок, обращения к операционной системе. Взаимодействие с компонентами обрабатывается операционной системой. Эмулирующий инструментальный набор виджетов реализует компонент без обращения к операционной системе и самостоятельно обрабатывает нажатия мыши и клавиатуры, рисует, управляет фокусом и другими функциями и не перекладывает все это на операционную систему. Обе конструкции имеют свои сильные и слабые стороны.

Нативные инструментальные наборы виджетов являются «самим совершенством». Их виджеты выглядят на рабочем столе также, как и их аналоги из других приложений. Поставщики операционных систем постоянно изменяют внешний вид своих виджетов и добавляют новые возможности. Нативные инструментальные наборы виджетов получают эти обновления без всяких дополнительных затрат. К сожалению, нативные инструментальные наборы трудно реализовывать, поскольку виджеты операционной системы, на базе которых они создаются, значительно различаются и приводит ведет к противоречиям и программы теряют свойство переносимости.

Эмулирующий инструментальный набор виджетов либо имеет свой собственный внешний вид, либо пытается выполнять отрисовку и вести себя точно так, как это происходит в операционной системе. Их главное преимущество над нативными наборами состоит в их гибкости (хотя современные нативные инструментальные наборы виджетов, например, Windows Presentation Framework (WPF), столь же гибки). Поскольку код, реализующий виджет, является частью инструментария, а не встроен в операционную систему, виджет может выполнять отрисовку и вести себя любым образом. Программы, в которых используются эмулирующие инструменталные наборы виджетов, хорошо переносятсяс одной системы на другие. Ранние варианты эмулирующих инструменталных наборов виджетов имели плохую репутацию. Часто они были медлительными и плохо эмулировали работу операционной системы и из-за этого они выглядели неуместно на рабочем столе. В частности, в то время можно было легко отличить программы на языка Smalltalk-80 из-за того, что в них использовались эмулирующие виджеты. Пользователи знали, что они управляли «программой Smalltalk» и это плохо влияло на принятие приложений, написанных на Smalltalk.

В отличие от других языков программирования, например, C и C++, первые версии языка Java были доступны с нативным инструментальным набором виджетов, который имел название Abstract Window Toolkit (AWT). Считается, что AWT достаточно ограниченный, имеет ошибки, противоречивый и все его ругают. Фирма Sun и многие другие, отчасти из-за того, что имели опыт работы с AWT, считали нативный инструментальный набор виджетов, который был переносимым и обладал хорошей производительностью, непригодным для работы. Решением был Swing, полнофункциональный эмулирующий инструментальный набор виджетов.

Приблизительно в 1999 году подразделение OTI использовало язык Java для реализации продукта под названием VisualAge Micro Edition. В первой версии VisualAge Micro Edition использовался Swing, причем опыт OTI по использованию Swing не был положительным. Ранние версии Swing имели ошибки, работали долго и тратили много памяти, а аппаратура того времени была недостаточно мощной с тем, чтобы обеспечить приемлемую производительность. Подразделение OTI успешно создало нативный инструментальный набор виджетов для Smalltalk-80 и для других реализаций Smalltalk, в результате чего язык Smalltalk был встречен с одобрением. Этот опыт был использован для создания первой версии пакета SWT. VisualAge Micro Edition и SWT имели успех и когда началась работа над Eclipse, естественным выбором стал SWT. Использование в Eclipse пакета SWT поверх Swing раскололо сообщество Java. Некоторые видели заговор, но Eclipse добился успеха и использование SWT отличало его от других программ на языке Java. Eclipse обладал достаточной производительностью, имел хороший внешний вид и общее настроение было следующим: «Я не могу поверить, что это программа на языке Java».

Ранние варианты Eclipse SDK работали на Linux и Windows. В 2010 году появилась поддержка более десятка платформ. Разработчик может написать приложение для одной платформы, и развернуть его на нескольких платформах. В то время в рамках сообщества Java разработка нового инструментального набора виджетов для Java считалась спорным вопросом, но те, кто работал с Eclipse, чувствовали, что нужно потратить усилия и получить лучшее нативное решение, используемое на рабочем столе. Это справедливо и сегодня, и есть миллионы строк кода, зависящего от SWT.

JFace представляет собой слой поверх SWT, предоставляющий инструментальные средства для обычных задач программирования пользовательского интерфейса, например, фреймворки для работы с настройками и визардами. Точно также, как и SWT, он разрабатывался так, чтобы его можно было использовать со многими оконными системами. Тем не менее, это чистый код на языке Java, в котором нет нативного кода платформ.

В рамках платформы также предоставляется интегрированная система подсказок, базирующаяся на небольших блоках информации, которые называются темами. Тема состоит из метки и ссылки на место, где находится сама тема. Ссылка может указывать на HTML-файл с документацией или на XML-документ, описывающий дополнительные ссылки. Темы группируются вместе в виде оглавлений (в TOC). Темы можно рассматривать как листья, а TOC - как три подразделения организации. Для того, чтобы добавить содержимое подсказки к вашему приложению, вы можете к точке расширения org.eclipse.help.toc добавить org.eclipse.platform.doc.isv plugin.xml так, как это сделано ниже.

<?xml version="1.0" encoding="UTF-8"?>
<?eclipse version="3.0"?>
<plugin>

<!-- ===================================================================== -->
<!-- Определяем первичное содержимое TOC                                   -->
<!-- ===================================================================== -->
   &l;textension
         point="org.eclipse.help.toc">
      <toc
            file="toc.xml"
            primary="true">
      </toc>
      <index path="index"/>
   </extension>
<!-- ===================================================================== -->
<!-- Определяем содержимое TOC для подтем                                  -->
<!-- ===================================================================== -->
   <extension
         point="org.eclipse.help.toc">
      <;toc
            file="topics_Guide.xml">
      </toc>
      &l;ttoc
            file="topics_Reference.xml">
      </toc>
      <toc
            file="topics_Porting.xml">
      </toc>
      <toc
            file="topics_Questions.xml">
      &l;t/toc>
      <toc
            file="topics_Samples.xml">
      </toc>
   </extension>

Для индексирования и онлайнового поиска содержимого подсказок используется пакет Apache Lucene. В ранних вариантах Eclipse онлайновая подсказка представляла собой веб-приложение, работающая на сервере Tomcat. Кроме того, за счет предоставления подсказки в самом Eclipse, у вас также могли использовать некоторое подмножество плагинов, предназначенные для работы с подсказками, для того, чтобы создавать автономно работающие серверы подсказок [3].

Eclipse также предоставлял поддержку среды командной работы с репозитарием исходного кода, создания патчей и других обычно выполняемых задач. В рабочем пространстве предоставлялся набор файлов и метаданных, с помощью которых ваша работа будет сохранена в файловой системе. Имелся также отладчик, позволяющий отслеживать проблемы в коде Java, а также фреймворк для построения отладчиков для конкретных языков.

Одна из целей проекта Eclipse состояла в поддержке разработчиков программ с открытым исходным кодом и разработчиков коммерческих программ в использовании данной технологии для расширения платформы в соответствие с их собственными потребностями, причем один из способов поощрения применения данной технологии заключался в предоставлении стабильного интерфейса API. API можно рассматривать как технический контракт, определяющий поведение вашего приложения. Его также можно рассматривать как социальный контракт. В проекте Eclipse есть мантра - «API - навсегда». Так что следует быть исключительно аккуратным когда пишется интерфейс API и учитывать, что он предназначен для использования на неопределенно долгий срок. Стабильное API представляет собой контракт между клиентом или потребителем и поставщиком API. Этот контракт гарантирует, что клиент может положится на использование платформы Eclipse, API в которой предоставляет на длительный срок, и не надо будет беспокоиться о том, что потребуется рефакторинг части клиентского приложения. Хороший интерфейс API также достаточно гибок с тем, чтобы можно было развивать его реализации.

6.1.2. Инструментарий Java Development Tools (JDT)

В JDT предоставляются редакторы языка Java, визарды, средства поддержки рефакторинга, отладчик, компилятор и инкрементный сборщик. Компилятор также помогает в наборе кода, навигации по коду и имеет другие функции редактирования. Java SDK не поставляется вместе с Eclipse, так что от пользователя зависит, какой SDK он установит на своем компьютере. Почему команда, разрабатывающая JDT, написала отдельный компилятор для компиляции Java-кода в Eclipse? Первоначально был взят код компилятора из проекта VisualAge Micro Edition. Планировалось поверх компилятора создать инструментальные средства, так что написание самого компилятора было логичным решением. Этот подход также позволил разработчикам JDT создавать точки расширения с тем, чтобы можно было расширять компилятор. Это было бы трудно делать, если бы компилятор был работающим из командной строки приложением, предоставляемым третьей стороной.

Написание своего собственного компилятора позволяет иметь средство, которое обеспечивает поддержку инкрементного сборщика, имеющегося в IDE. Инкрементный сборщик позволяет улучшить производительность, т. к. он заново компилирует только те файлы или их зависимости, которые были изменены. Как работает инкрементный сборщик? Когда вы в Eclipse создаете проект на языке Java, вы создаете ресурсы в рабочем пространстве, в котором хранятся ваши файлы. Сборщик в Eclipse берет входные файлы из вашего рабочего пространства (файлы .java) и создает выходные файлы (файлы .class). По состоянию процесса сборки сборщик знает о типах (классах или интерфейсах), имеющихся в рабочем пространстве, и то, как они соотносятся друг с другом. Состояние процесса сборки сообщается сборщику компилятором каждый раз, когда компилируется некоторый исходный файл. Когда выполняется инкрементная сборка, сборщику сообщается только об изменениях в ресурсах, т. е. сообщается о всех новых, измененных или удаленных файлах. Если файлы удаляются, то удаляются соответствующие файлы классов. Новые или измененные типы добавляются в очередь. Файлы в очереди компилируются один за другим и сравниваются со старым файлом класса для того, чтобы определить, были ли структурные изменения. Структурные изменения представляют собой модификации класса, которые могут влиять на другой тип, ссылающийся на него, например, изменение сигнатуры метода, добавление или удаление метода. Если происходят структурные изменения, то все типы, которые ссылаются на него, также добавляются в очередь. Если тип вообще был изменен, то новый файл класса записывается в каталог выходных файлов сборщика. Состояние процесса сборки обновляется в соответствие с информацией о скомпилированном типе. Этот процесс повторяется для всех типов в очереди до тех пор, пока очередь не будет пуста. Если есть ошибки компиляции, редактор языка Java создаст маркеры, указывающие на проблему. На протяжении многих лет инструментальные средства, предоставляемые в составе JDT, постоянно расширялись согласованно с появлением новых версий самой среды выполнения Java.

6.1.3. Среда разработки плагинов PDE

В среда разработки плагинов Plug-in Development Environment (PDE) предоставляются инструментальные средства для разработки, сборки, установки и тестирования плагинов и других артефактов, которые используются для расширения функциональных возможностей платформы Eclipse. Поскольку плагины Eclipse были новым типом артефактов в мире Java, не было системы сборки, которая могла бы преобразовывать исходный код в плагины. Поэтому команда разработчиков PDE написала компонент, называемый Сборщик PDE (PDE Build), который проверял зависимости плагинов и создавал скрипты Ant для сборки этих артефактов.

6.2. Eclipse 3.0: Среда времени выполнения, RCP и роботы

6.2.1. Среда времени выполнения

Eclipse 3.0 был, вероятно, одним из самых важных релизов проекта Eclipse благодаря ряду существенных изменений, которые произошли в течение этого цикла выпуска. В архитектуре Eclipse, которая предваряла версию 3.0, компонентная модель Eclipse состояла из плагинов, которые могли взаимодействовать друг с другом двумя способами. Во-первых, зависимости между ними могли быть выражены при помощи инструкции requires в файлах plugin.xml. Если плагину A требуется плагин B, то плагин A может видеть все Java классы и ресурсы из B, учитывая, конечно, соглашения о видимости классов языка Java. Каждый плагин имел версию, так что также можно было указывать версии зависимостей. Во-вторых, компонентная модель предлагала использовать расширения и точки расширения. Исторически сложилось, что разработчики, использующие Eclipse, написали свою собственную среду выполнения для Eclipse SDK, которая управлялае загрузкой классов, зависимостями плагинов и расширениями и точками расширений.

Проект Equinox был создан как новый инкубационный проект в рамках Eclipse. Целью проекта Equinox была замена компонентной модели Eclipse тем, что уже существовало, а также возможность обеспечить поддержку динамических плагинов. Среди рассматриваемых решений были JMX, Jakarta Avalon и OSGi. JMX не была полностью разработанной компонентной моделью и поэтому было решено, что ее использовать нецелесообразно. Jakarta Avalon не был выбран потому, что, как оказалось, он уже перестал существовать как проект. В дополнение к техническим требованиям, также было важно учитывать сообщество, которое поддерживает конкретную технологию. Будет ли сообщество готооы принять изменения, связанные с Eclipse? Готовы ли оно активно развиваться и расширяться в новых условиях? Команда разработчиков Equinox понимала, что сообщество, сгруппировавшееся вокруг технологии, которую они в конце концов выберут, столь же важно, как и технические соображения.

После исследования и оценки имеющихся альтернатив, разработчики выбрали проект OSGi. Почему проект OSGi? В нем была семантическая схема именования версий, используемая для управления зависимостями. Это позволяло воспользоваться модульным фреймворком, которого не хватало в самом JDK. Пакеты, которые предоставлялись для других сборок, должны были явно экспортироваться, а все остальные пакеты были скрыты. В OSGi был свой собственный загрузчик классов, поэтому команде разработчиков Equinox не требовалось продолжать поддерживать свои собственные загрузчики. Благодаря тому, что была стандартизирована компонентная модель, распространение которой не ограничивалось только экосистемой Eclipse, было понятно, что можно было обращаться к более широкой аудитории, и шире распространять проект Eclipse.

Команда разработчиков Equinox чувствовала себя уверенно, т. к. у проекта OSGi уже было энергичное сообщество; и они могли работать с этим сообществом, что помогло добавить функциональные возможности компонентной модели, необходимые проекту Eclipse. Например, на тот момент в OSGi поддерживались требования делать перечисления свойств на уровне пакетов, а не на уровне плагинов, как это требовалось в Eclipse. Кроме того, в OSGi на тот момент еще не использовалась концепция фрагментов, которая в Eclipse являлясь предпочтительным механизмом добавления кода, специфического для конкретной платформы или среды окружения, в существующий плагин. Например, фрагменты кода, предоставляемые для работы в файловых системах Linux и Windows, а также фрагменты, предназначенные для трансляции на другие языки. Как только было принято решение в качестве новой среды выполнения начать использовать OSGi, разработчикам потребовалась реализация фреймворка с открытым исходным кодом. Был проанализирован проект Oscar, который был предшественником проекта Apache Felix, а также фреймворк управления сервисами Service Management Framework (SMF), разработанный фирмой IBM. На тот момент Оскар был исследовательским проектом с ограниченными возможностями внедрения. В конечном итоге был выбран проект SMF, т.к. он уже использовался в поставляемых изделиях и, таким образом, был признан в качестве используемого в среде уровня предприятиями. За эталонную реализацию спецификации OSGi была взята реализация Equinox.

Был также реализован слой совместимости, так что в версии 3.0 по-прежнему должны работать уже существующие плагины. Просить разработчиков переписать их плагины согласно изменениями в базовой инфраструктуре Eclipse 3.0 было бы для проекта Eclipse, как инструментальной платформы, импульсом в направлении тупика. По ожиданиям разработчиков, использующих Eclipse, платформа должна просто продолжать работать.

С переходом на OSGi, плагины Eclipse стали еще называться сборками. Плагин и сборка являются одним и тем же: они оба обеспечивают модульное подмножество функциональных возможностей, которое описывается с помощью метаданных в манифесте. Раньше зависимости, экспортируемые пакеты и расширения, а также точки расширения описывались в файле plugin.xml. С переходом на сборки OSGi, описания расширений и точек расширений продолжали указываться в файле plugin.xml, поскольку они относятся к понятиям проекта Eclipse. Остальная информация описывалась в файле META-INF/MANIFEST.MF, т. е. в манифесте сборки версии OSGi. Чтобы поддержать такое изменение, в PDE был предоложен новый редактор манифестов для использования внутри Eclipse. Каждая сборка имеет имя и версию. Манифест для сборки org.eclipse.ui выглядит следующим образом:

Manifest-Version: 1.0
Bundle-ManifestVersion: 2
Bundle-Name: %Plugin.name
Bundle-SymbolicName: org.eclipse.ui; singleton:=true
Bundle-Version: 3.3.0.qualifier
Bundle-ClassPath: .
Bundle-Activator: org.eclipse.ui.internal.UIPlugin
Bundle-Vendor: %Plugin.providerName
Bundle-Localization: plugin
Export-Package: org.eclipse.ui.internal;x-internal:=true
Require-Bundle: org.eclipse.core.runtime;bundle-version="[3.2.0,4.0.0)",
 org.eclipse.swt;bundle-version="[3.3.0,4.0.0)";visibility:=reexport,
 org.eclipse.jface;bundle-version="[3.3.0,4.0.0)";visibility:=reexport,
 org.eclipse.ui.workbench;bundle-version="[3.3.0,4.0.0)";visibility:=reexport,
 org.eclipse.core.expressions;bundle-version="[3.3.0,4.0.0)"
Eclipse-LazyStart: true
Bundle-RequiredExecutionEnvironment: CDC-1.0/Foundation-1.0, J2SE-1.3

Начиная с Eclipse 3.1, в манифесте также можно определять среду выполнения, необходимую для работы сборки (bundle required execution environment - BREE). Среда выполнения указывалась как минимальная среда Java, необходимая для работы сборки. Компилятор Java не умеет разбираться в сборках и манифестах OSGi. В PDE есть инструментальные средства для разработки сборок OSGi. Поэтому PDE выполняет анализ манифеста сборки и создает для этой сборки путь classpath. Если вы в своем манифесте указали в качестве среды исполнения среду J2SE-1.4, а затем написали код, в котором есть обобщенные типы generic, то вам будет предложено исправить ошибки кода. Тем самым гарантируется, что ваш код соответствует контракту, указанному в манифесте.

В OSGi предоставляется модульный фреймворк для языка Java. Фреймворк OSGi осуществляет управление коллекциями самодокументированных сборок и загрузкой их классов. В каждом пакете есть свой собственный загрузчик классов. Путь classpath, используемый в сборке, строится путем проверки зависимостей, указанных в манифесте, и собственно генерации пути classpath. Приложения OSGi являются коллекциями сборок. Для того, чтобы в полной мере воспользоваться модульностью, вы должны уметь выразить зависимости вашего приложения в виде, понятном для потребителей. Поэтому в манифесте описываются экспортируемые пакеты, доступные для клиентов этой сборки, которые представляют собой общедоступный интерфейс API, предоставляемый для использования. В сборке, в которой используется этот интерфейс API, должен быть соответствующий импорт используемых пакетов. Манифест также позволяет вам указать диапазоны версий ваших зависимостей. Взгляните на инструкцию Require-Bundle, которая имеется в приведенном выше манифесте, и обратите внимание на то, что сборка org.eclipse.core.runtime, от которой зависит org.eclipse.ui, должна иметь версию не меньше, чем 3.2.0 и не больше, чем 4.0.0.

Рис.6.4: Жизненный цикл сборки OSGi

OSGi является динамическим фреймворком, который поддерживает установку, запуск, остановку и удаление сборок. Как упоминалось ранее, одним из основных преимуществ Eclipse является ленивая или отложенная активация, когда классы плагинов не загружаются до тех, пока они не становятся необходимыми. Жизненный цикл сборок OSGi также позволяет использовать этот подход. Когда вы запускаете приложение OSGi, сборки находятся в состоянии installed или «установлено». Если зависимости сборки разрешены, то сборка переходит в состоянии resolved или «зависимости разрешены». Как только зависимости будут разрешены, классы, находящиеся внутри этой сборки, могут загружаться и выполняться. Состояние starting или «запуск» означает, что сборка активизируется в соответствии с ее политикой активации. После того, как сборка активирована (т. е. находится в состоянии activate), она находится в состоянии active или «активном» состоянии и может запрашивать необходимые ресурсы и взаимодействовать с другими сборками. Сборка находится в состоянии stopping или «остановлено», когда выполняется метод stop ее активатора с тем, чтобы освободить все ресурсы, которые были открыты, когда сборка была активной. Наконец, сборка может быть «удалена» (т. е. находиться в состоянии uninstalled), а это значит, что она будет недоступна для использования.

Т.к. интерфейс API развивается, то нужен способ сообщать потребителям вашего API об изменениях. Один подходов состоит в использовании семантической схемы именования версий ваших сборок и указания в манифестах диапазонов версий для вашей зависимости. В OSGi используется схема именования версий, имеющая четыре части и показанная на рис.6.5.

Рис.6.5: Схема именования версий

В схеме нумерации версий OSGi, каждая сборка имеет уникальный идентификатор, состоящий из имени и четырех частей номера версии. Идентификатор и версия являются вместе для потребителя уникальным набором байтов. Согласно правилам Eclipse, если вы делаете изменения в сборке, то каждая часть номера версии будет указывать потребителям вид сделанных изменений. Таким образом, если вы хотите указать, что вы намереваетесь изменить API, вы на единицу увеличиваете первую (major) часть номера версии. Если вы просто расширили API, вы увеличиваете на единицу вторую часть (minor) номера версии. Если вы исправили небольшую ошибку, которая не влияет на API, то на единицу увеличивается третья часть (service) номера версии. Наконец, когда на единицу увеличивается четвертая часть номера (qualifier), то он указывает на новый идентификатор собранного варианта или тег репозитория с исходным кодом.

Кроме возможности определять фиксированные зависимости между сборками, в OSGi также есть механизм, называемый сервисами, который позволяется реализовывать дополнительную развязку между сборками. Сервисами являются объекты с набором свойств, которые регистрируются в реестре сервисов OSGi. В отличие от расширений, которые регистрируются в реестре расширений в тот момент, когда Eclipse сканирует сборки при запуске, сервисы регистрируются динамически. В сборку, в которой используется сервис, нужно импортировать пакет, определяющий сервис-контракт, а фреймворк определяет реализацию сервиса, взятого из реестра сервисов.

Есть определенное приложение, предназначенное для запуска Eclipse, которое похоже на метод main в файле классов Java. Приложения Eclipse определяются с помощью расширений. Например, приложением для запуска самого Eclipse IDE является приложение org.eclipse.ui.ide.workbench, которое определено в сборке org.eclipse.ui.ide.application.

<plugin>
    <extension
         id="org.eclipse.ui.ide.workbench"
         point="org.eclipse.core.runtime.applications">
      <application>
         <run
               class="org.eclipse.ui.internal.ide.application.IDEApplication">
         </run>
      </application>
  </extension>
</plugin>

Есть много приложений, предоставляемых в Eclipse, например, приложения для автономного запуска серверов подсказки, выполнения задач Ant и тестов JUnit.

6.2.2. Платформа Rich Client Platform (RCP)

Одна из самых интересных особенностей, касающаяся работы в сообществе, использующим открытый исходный код, состоит в том, что программы могут применяться совершенно неожиданным образом. Первоначальным назначением проекта Eclipse было предоставление платформы и инструментальных средств, позволяющих создавать и расширять различные IDE. Однако к тому времени, когда был выпущен релиз 3.0, по отчетам об ошибках стало ясно, что сообщество взяло некоторое количество сборок, предназначенных для разработки платформы, и использовало их для сборки многофункциональных приложений Rich Client Platform (RCP), которые многие рассматривают как приложения на языке Java. Т.к. первоначально Eclipse был ориентирован на создание IDE, то для того, чтобы новый вариант применения мог был проще воспринят сообществом, потребовался определенный рефакторинг проекта. В приложениях RCP не требуются все функциональные возможности, которые нужны в IDE, так что некоторые сборки были разделены на более мелкие, которые могут использоваться сообществом при построении приложений RCP.

Примерами приложений RCP, используемых в реальных условиях, является применение платформы RCP для мониторинга роботов-марсоходов, разработанных НАСА в лаборатории Jet Propulsion Laboratory, использование проекта Bioclipse для визуализации данных биоинформатики и использование проекта Dutch Railway для мониторинга загруженности поездов. Общая мысль, проходящая через многие из этих приложений, заключается в том, что эти команды разработчиков решили, что могут взять утилиты, предлагаемые платформой RCP, и сконцентрироваться на создании своих специальных инструментальных средств, работающих поверх этих утилит. Они могут сэкономить время разработки и деньги благодаря тому, что могут сосредоточиться на создании своих инструментальных средств на базе платформы со стабильным интерфейсом API, что гарантирует, что технологии, выбранные ими, будут поддерживаться достаточно долго.

Рис.6.6: Архитектура Eclipse 3.0

Если вы посмотрите на архитектуру 3.0, приведенную на рис.6.6, то увидите, вы заметите, что для поддержки модели приложений и реестра расширений все еще присутствует среда времени выполнения Eclipse Runtime. Управление зависимостями между компонентами, т. е. Управление моделью плагинов, в настоящее время происходить при помощи OSGi. Кроме того, что пользователи могут продолжать расширять Eclipse с целью создания своих собственных сред разработки, они также могут на базе фреймворка приложений RCP собирать приложения более общего назначения.

6.3. Eclipse 3.4

Считается само собой разумеющейся возможность легкого обновления приложения до новой версии и добавление нового контента. В Firefox это происходит без проблем. Для Eclipse, это делать было не так просто. Первоначальным механизмом был менеджер обновлений (Update Manager), который использовался для добавления нового контента в инсталляцию Eclipse или обновление ее до новой версии.

Чтобы понять, что изменяется во время обновления или инсталляции, необходимо понять, что в Eclipse имеется в виду под понятием «возможности». Возможность является артефактом PDE, в котором определен набор пакетов, упаковываемых вместе в таком формате, который может быть собран или установлен в виде одного релиза. Возможность может также включать в себя другие возможности. Смотрите рис.6.7.

Рис.6.7: Иерархия возможностей Eclipse 3.3 SDK

Если вы хотели обновить свою инсталляцию Eclipse до нового релиза, в котором добавлен только один новый пакет, то из-за того что менеджер обновлений является достаточно грубым механизмом, потребуется обновить всю возможность целиком. Обновление возможности, в которой исправлен один пакет, является неэффективным.

Есть визарды PDE для создания возможностей и полученные с их помощью релизы помещаются в ваше рабочее пространство. В файле feature.xml указываются сборки, которые входят в состав конкретной возможности, а также указываются некоторые простые свойства сборок. Возможность, например, пакет, имеет имя и версию. В состав возможностей могут входить другие возможности., а также могут указываться диапазоны версий входящих возможностей. Пакеты, входящие в состав возможности, перечисляются с указанием конкретных свойств. Например, можно увидеть, что во фрагменте org.eclipse.launcher.gtk.linux.x86_64 указывается операционная система (os), система окон (ws) и архитектура (arch) той среды, где эту возможность можно использовать. Поэтому при обновлении до новой версии этот фрагмент будет устанавливаться только на указанной платформе. Такие фильтры платформ имеются в манифесте OSGi пакета.

<?xml version="1.0" encoding="UTF-8"?>
<feature
      id="org.eclipse.rcp"
      label="%featureName"
      version="3.7.0.qualifier"
      provider-name="%providerName"
      plugin="org.eclipse.rcp"
      image="eclipse_update_120.jpg">

   <description>
      %description
   </description>

   <copyright>
      %copyright
   </copyright>

   <license url="%licenseURL">
      %license
   </license>

   <plugin
         id="org.eclipse.equinox.launcher"
         download-size="0"
         install-size="0"
         version="0.0.0"
         unpack="false"/>

   <plugin
         id="org.eclipse.equinox.launcher.gtk.linux.x86_64"
         os="linux"
         ws="gtk"
         arch="x86_64"
         download-size="0"
         install-size="0"
         version="0.0.0"
         fragment="true"/>

Приложение Eclipse состоит не только из возможностей и сборок. Из следующего списка файлов, входящих в состав приложения Eclipse, видно, что есть платформозависимые исполняемые файлы, предназначенные для запуска самого Eclipse, файлы лицензий и платформозависимые библиотеки.

com.ibm.icu
org.eclipse.core.commands
org.eclipse.core.conttenttype
org.eclipse.core.databinding
org.eclipse.core.databinding.beans
org.eclipse.core.expressions
org.eclipse.core.jobs
org.eclipse.core.runtime
org.eclipse.core.runtime.compatibility.auth
org.eclipse.equinox.common
org.eclipse.equinox.launcher
org.eclipse.equinox.launcher.carbon.macosx
org.eclipse.equinox.launcher.gtk.linux.ppc
org.eclipse.equinox.launcher.gtk.linux.s390
org.eclipse.equinox.launcher.gtk.linux.s390x
org.eclipse.equinox.launcher.gtk.linux.x86
org.eclipse.equinox.launcher.gtk.linux.x86_64

Эти файлы невозможно обновить с помощью менеджера обновлений, поскольку опять же, мы имеем дело только с возможностями. Поскольку многие из этих файлов обновлялись в каждом крупном релизе, это означало, что пользователи каждый раз, когда был новый релиз, должны были загружать новый файл zip, а не обновлять существующую инсталляцию. Это не подходило сообществу Eclipse. В PDE предоставлялась файловая поддержка, которая позволяла указывать все файлы, необходимые для создания приложений Eclipse RCP. Однако в менеджере обновлений не было механизма переноса этих файлов в вашу инсталляцию, что в равной мере разочаровывало как пользователей, так и разработчиков. В марте 2008 года в SDK появился компонент p2, представляющий собой новое решение, предназначенное для работы с ресурсами. Что обеспечить обратную совместимости, по-прежнему можно использовать менеджер обновлений, но по умолчанию применяется p2.

6.3.1. Концепции p2

В Equinox p2 все связано с инсталляционными единицами (installation units - IU). IU представляет собой определение имени и идентификатора артефакта, который вы устанавливаете. В этих метаданных также описываются возможности артефакта (что предлагается) и его требования (его зависимости). В метаданных также указывается фильтры применимости (область применения) в случае, если артефакт можно применять только в определенной среде. Например, фрагмент org.eclipse.swt.gtk.linux.x86 можно применять только в случае, если вы устанавливаете его на машине x86 с системой Linux и GTK. По сути, метаданные является формой представления информации, которая есть в манифесте сборки. Артефакты являются просто двоичными модулями, которые устанавливаются. Такое разделение достигается путем отделения метаданных от описываемых с их помощью артефактов. Репозитарий p2 состоит из репозитория метаданных и репозитария артефактов.

Рис.6.8: Концепции системы p2

Профиль представляет собой список фрагментов IU в вашем варианте инсталляции. Например, ваш Eclipse SDK имеет профиль, который описывает вашу текущую инсталляцию. Внутри Eclipse вы можете запросить обновление до более новой версии сборки, для которой будет создан новый профиль с другим набором фрагментов IU. В профиле также указывается список свойств, связанных с инсталляцией, например, операционная система, система управления окнами и параметры архитектуры. В профилях также запоминается каталог инсталляции и место, где он находится. Профили находятся в реестре профилей, в котором могут храниться несколько профилей. Концепция директора (director) отвечает за вызов операций, с помощью которых предоставляются ресурсы. Она работает с концепциями планировщика (planner) и движка (engine). Планировщик рассматривает существующий профиль, и определяет, какие операции нужно выполнить для того, чтобы преобразовать имеющийся вариант инсталляции в новое состояние. Движок отвечает за выполнение фактических операций предоставления ресурсов и за установку новых артефактов на диск. Концепция точек стыковки (touchpoints), являющаяся частью концепции движка, используется в тот момент, когда осуществляется установка. Например, для Eclipse SDK, есть точки стыковки Eclipse, зная которые можно устанавливать сборки. Для системы Linux, в которой Eclipse устанавливается из двоичных файлов RPM, движок должен использовать точки стыковки RPM. Кроме того, p2 может выполнять установку в параллель с другой работой или отдельно в отдельном процессе, таком как сборка приложения.

Новая система провизирования p2 имела много преимуществ. Артефакты, установленные в Eclipse, могли обновляться от выпуска к выпуску. Поскольку предыдущие профили хранились на диске, имелся также способ вернуться к предыдущему варианту инсталляции Eclipse. Кроме того, при наличии профиля и репозитария, вы могли воспроизвести вариант инсталляции Eclipse пользователя, который сообщил об ошибке, и попытаться воспроизвести проблему на своем собственном рабочем столе. Механизм предоставление ресурсов с помощью системы p2 давал больше, нежели просто возможность обновлять и устанавливать Eclipse SDK; это была платформа, которую можно было также применять и в случаях с RCP и OSGi. Команда разработчиков Equinox также работала с разработчиками другого проекта Eclipse, Eclipse Communication Framework (ECF), с тем, чтобы реализовать надежный транспорт, необходимый для артефактов и метаданных в репозитариях p2.

Когда в SDK была выпущена система p2, в сообществе Eclipse было много оживленных дискуссий. Поскольку менеджер обновлений был далеко не самым оптимальным решением в качестве системы провизирования конкретной инсталляции Eclipse, у пользователей Eclipse вошло в привычку распаковывать сборки в собственный вариант инсталляции и перезапускать Eclipse. Такой подход позволял надежным образом разрешать зависимости между сборками. Это также означало, что любые конфликты в вашей инсталляции разрешались во время выполнения, а не время установки. Ограничения следует учитывать во время установки, а не во время выполнения. Тем не менее, пользователи часто не обращали внимания на эти вопросы и поскольку сборки находились на диске, они считали, что сборки работали. Еще ранее варианты обновления, которые предлагал Eclipse, были простым каталогом, в котором находились сборки и возможности, представляющие собой архивы jar. В простом файле site.xml указывались имена возможностей, которые можно было использовать в обновленном варианте. С появлением p2, метаданные, которые были предоставлены в репозитариях p2, стали гораздо сложнее. Чтобы создать метаданные, процесс сборки необходимо было перенастроить так, чтобы либо генерировать метаданные во время сборки, либо запускать задачу генерации уже поверх уже существующих сборок. Первоначально не было доступной документации, в которой описывалось как делать эти изменения. А также, как это всегда бывает при предъявлении новой технологии более широкой аудитории, выявлялись неожиданные ошибки, которые следовало решать. Однако, когда было написано больше документации и были потрачены долгие часы на устранение всех этих ошибок, команда разработчиков Equinox смогла справиться со всеми этими проблемами и теперь система p2 стала базовым движком предоставления ресурсов, на котором основываются многие коммерческие предложения. Кроме того, фонд Eclipse Foundation каждый год предоставляет свой скоординированный релиз, используя для этого единый репозитарий p2 для всех проектов, в разработке которых он принимает участие.

6.4. Eclipse 4.0

Архитектуру нужно постоянно контролировать с тем, чтобы знать, является ли она по-прежнему актуальной. Есть ли возможность добавлять новые технологии? Должна ли она поощрять рост сообщества? Легко ли привлекать новых участников? В конце 2007 года участники проекта Eclipse решили, что ответов на эти вопросы не было и они приступили к разработке нового варианта видения проекта Eclipse. В то же самое время им стало понятно, что есть тысячи приложений Eclipse, которые зависят от существующего API. В конце 2008 года был создан технологический проект уровня инкубатора с целью решить следующие три конкретные задачи: сделать более простой модель программирования в Eclipse, привлечь новых участников проекта и позволить платформе воспользоваться новыми веб-технологиями, обеспечивая при этом открытость архитектуры.

Рис.6.9: Ранний вариант релиза Eclipse 4.0 SDK

Первый пробный вариант релиза Eclipse 4.0 был в июле 2010 года с целью получить обратную связь от пользователей. Он состоял из комбинации сборок SDK, которые были частью релиза 3.6, а также новых сборок, которые были разработаны в рамках технологического проекта. Как и в случае релиза 3.0, был реализован слой совместимости, с тем чтобы существующие сборки могли работать с новым релизом. Как всегда, присутствовала оговорка, что для того, чтобы обеспечить эту совместимость. необходимо использовать общедоступный интерфейс API. Такой гарантии не было, если в вашей сборке использовался внутренний код предыдущих релизов. В релизе 4.0 была предложена платформа приложений Eclipse 4 Application Platform, обладающая следующими возможностями.

6.4.1. Рабочее пространство модели

В версии 4.0 рабочее пространство модели создается при помощи фреймворка Eclipse Modeling Framework (EMFgc). Есть различие между моделью (model) и внешним видом (view), т.к. механизм, осуществляющий визуализацию, опрашивает модель, а затем генерирует код SWT. По умолчанию используются средства визуализации SWT, но допустимы другие решения. Если вы создаете пример приложения 4.x, то для модели рабочего пространства, используемого по умолчанию, будет создан файл XMI. Модель можно изменять и рабочее пространство будет мгновенно обновляться в соответствие с изменениями в модели. На рис.6.10 приведен пример модели, созданной для примера приложения 4.x.

Рис.6.10: Модель, созданная для примера приложения 4.x

6.4.2. Стиль каскадных стилевых страниц

Eclipse был выпущен в 2001 году, еще до эпохи появления многофункциональных интернет-приложений, внешний вид которых можно изменяться с помощью CSS. В Eclipse 4.0 есть возможность использовать стили для более простого изменения внешнего вида приложения Eclipse. По умолчанию таблицы стилей CSS можно найти в каталоге css в сборке org.eclipse.platform.

6.4.3. Внедрение зависимостей

Примерами моделей программирования сервисов являются реестр расширений Eclipse и сервисы OSGi. В соответствие с соглашением, в модели программирования сервисов имеются поставщики и потребители сервисов. За налаживание отношений между поставщиками и потребителями несет ответственность брокер.

Рис.6.11: Взаимосвязь между поставщиками и потребителями

В приложениях Eclipse 3.4.x потребителю для того, чтобы использовать сервисы, обычно необходимо знать расположение реализаций сервисов, а также понимать порядок наследования во фреймворке. Поэтому код, который создает потребитель, становиться менее пригодным для повторного использования, т. к. те, кто им пользуется, не могут переопределить реализацию, которая определена потребителем. Например, если вы хотите обновить сообщение в строке состояния в Eclipse 3.x, то код будет выглядеть следующим образом:

getViewSite().getActionBars().getStatusLineManager().setMessage(msg);

Eclipse, 3,6 построен из компонентов, но многие из этих компонентов также слишком сильно взаимозависимы. Для того, чтобы можно было собирать приложения из менее связанных между собой компонентов, в Eclipse 4.0 для предоставления сервисов клиентам используется внедрение зависимостей (dependency injection). Внедрение зависимостей Eclipse, 4.х осуществляется через специальный фреймворк, в котором используется концепция контекста, служащая в качестве общего механизма поиска сервисов потребителями. Контекст существует между приложением и фреймворком. Контексты являются иерархическими. Если в контекст поступил запрос, который не удается разрешить, то произойдет делегирование запроса в родительский контекст. В контексте Eclipse, который называется IEclipseContext, хранятся имеющиеся сервисы и происходит поиск сервисов OSGi. По сути контекст похож на то, как в языке Java происходит отображение имен или классов в объекты. Контекст осуществляет обработку элементов модели и ее сервисов. Каждый элемент модели будет иметь контекст. Сервисы публикуются в версиях 4.х при помощи механизма сервисов OSGi.

Рис.6.12: Контекст брокера сервисов

Поставщики добавляют в контекст сервисы и объекты, которые там хранятся. Сервисы внедряются в объекты потребителей при помощи контекста. Потребитель объявляет, что ему нужно, и контекст определяет, как удовлетворить этот запрос. Такой подход упростил использование динамических сервисов. В Eclipse 3.x, потребителю нужно было подключать слушателей (listeners) для того, чтобы получать уведомления о том, когда сервисы становятся доступными или недоступными. В Eclipse 4.x как только контекст внедряется в объект потребителя, любые изменения будут автоматически снова поступать в этот объект. Иными словами, снова будет происходить внедрение зависимостей. Потребитель указывает, что он будет использовать контекст, с помощью аннотаций Java 5, которые соответствуют стандарту JSR 330, а именно с помощью @inject, а также с помощью некоторых специально настроенных аннотаций проекта Eclipse. Поддерживается внедрение в конструкторы, методы и поля. Среда выполнения релизов 4.x ищет в объектах такие аннотации. Выполняемое действие зависит от того, какая была найдена аннотация.

Такое разделение контекста и приложения позволяет улучшить возможность повторного использование компонентов, и освобождает потребителя от необходимости разбираться в их реализации. В релизе 4.x код, обновляющий строку состояния, будет выглядеть следующим образом:

@Inject
IStatusLineManager statusLine;
⋮    ⋮    ⋮
statusLine.setMessage(msg);

6.4.4. Сервисы приложений

Одной из главных задач в Eclipse 4.0 было создание настолько простого интерфейса API, что с его помощью было легко реализовывать сервисы общего назначения. Был создан список простых сервисов, считающихся «наиболее важными» и известными как сервисы приложений Eclipse Application. Задача состоит в предоставлении автономно работающего интерфейса API, которыми клиенты могут пользоваться без необходимости глубокого понимания всех доступных интерфейсов API. Сервисы приложений были реализованы в виде индивидуально работающих сервисов, так что ими также можно пользоваться в других языках, отличающихся от языка Java, таких как Javascript. Например, есть интерфейс API для доступа к модели приложения, который позволяет читать и изменять настройки приложения (preferences) и выдавать сообщения об ошибках и предупреждения.

6.5. Заключение

Архитектура Eclipse, базирующаяся на компонентах, разрабатывалась таким образом, чтобы можно было добавлять новые технологии и, при этом, сохранять обратную совместимость. На это потребовались затраты, но наградой был рост сообщества Eclipse, т. к. потребители были уверены, что они могут продолжать поставлять свои разработки, базирующиеся на стабильном API.

Eclipse используется настолько широко, причем варианты применения нашего обширного API настолько разнообразны, что новичкам становится трудно его адаптировать и понимать. Если оглянуться назад, то мы бы должны были делать так, чтобы наш интерфейс API оставался простым. Если 80% потребителей используют только 20% возможностей интерфейса API, то это означает, что есть необходимость сделать его более простым, и это одна из причин, почему была создана ветка Eclipse 4.х.

Мудрость толпы действительно подсказывает интересные случаи применения, например, деление IDE на сборки, которые могут использоваться для создания приложений RCP. С другой стороны, толпа часто создает много шума из-за просьб о решениях, относящихся к крайним случаям, на реализацию которых требуется значительное время.

В первые дни работы над проектом Eclipse, для разработчиков было роскошью тратить значительное количество времени на документацию, примеры и ответы на вопросы сообщества. Со временем, эта обязанность в целом перешла к сообществу Eclipse. Для того, чтобы помочь сообществу, мы могли бы написать лучшую документацию и привести лучшие варианты применения, но тогда было бы сложно реализовывать большое количество изменений и добавлений, которые планируются для каждого выпуска. Вопреки ожиданиям, что даты выпуска программ могут быть сдвинуты, мы продолжаем публиковать выпуски Eclipse во-время, что позволяет нашим потребителям надеяться, что они могут делать то же самое.

Мы адаптируем новые технологии, изобретаем, как будет выглядеть и работать Eclipse, и мы продолжаем общение с нашими потребителями, поддерживая их сообщество. Если вы хотите принимать участие в проекте Eclipse, пожалуйста, посетите сайт http://www.eclipse.org.

Примечания

  1. http://www.eclipse.org
  2. http://www.eclipse.org/equinox
  3. Например: http://help.eclipse.org.

7.1. Библиотека базы данных: хранение данных временных рядов

Graphite был написан на языке Python и состоит из трёх основных компонентов: библиотеки баз данных whisper, демона серверной стороны carbon, и клиентского веб-приложения, визуализирующего графики и предоставляющее базовый пользовательский интерфейс. Несмотря на то, что библиотека whisper была разработана специально для системы Graphite, она может использоваться независимо от этой системы. По своей конструкции она очень похожа на кольцевую базу данных, используемую в RRDtool, и хранит только числовые данные временных рядов. Обычно мы считаем, что базы данных являются серверными процессами, с которыми клиентские приложения общаются через сокеты. Однако, whisper точно также, как и RRDtool является библиотекой баз данных, используемой приложениями для обработки и поиска данных, хранящихся в файлах, отформатированных специальным образом. Самыми главными операциями библиотеки whisper являются операция create, создающая новый файл whisper, операция update, записывающая новые данные в файл, и операция {fetch, используемая для выборки данных.

Рис.7.1: Базовая структура файла whisper

Как показано на рис.7.1, файлы whisper состоят из секции заголовка, в котором находятся различные метаданные, и одной или более архивных секций, следующих далее. Каждый архив является последовательностью подряд идущих значений точек данных, представляющих собой пары (timestamp, value) (отметка о времени, значение). Когда выполняется операция обновления update или выборки fetch, whisper по отметке о времени и конфигурации архива определяет смещение в файле, в который должна быть сделана запись или из которого должно быть выполнено чтение.

7.2. Серверная часть: Простой сервис хранения данных

Серверная часть проекта Graphite представляет собой процесс-демон, называемый carbon-cache, на который обычно ссылаются как на carbon. Он собран на Twisted, хорошо масштабируемом управляемым событиями фреймворке ввода/вывода для языка Python. Twisted позволяет серверу carbon эффективно общаться с большим количеством клиентов и обрабатывать большой объём трафика с малыми накладными расходами. На рис.7.2 показан поток данных, идущий между carbon, whisper и веб-приложением: клиентские приложения собирают данные и отсылают их на серверную сторону Graphite в carbon, где данные хранятся в файлах whisper. Затем эти данные могут использоваться веб-приложением Graphite для создания графиков.

Рис.7.2: Поток данных

Основная функция демона carbon состоит в запоминании значений точек данных для метрик, предоставляемых клиентами. В терминологии Graphite метрикой является любая сущность, которую можно измерить и которая изменяется с течением времени (например, использование ресурсов процессора сервера или объем продаж какого-либо продукта). Точка данных является просто парой (timestamp, value), соответствующей значению конкретной метрики, измеренному в определенное время. Метрики идентифицируются уникальным образом с помощью собственного имени, причем имена каждой метрики точно также, как и точки данных передаются из клиентских приложений. Обычным типом клиентского приложения является агент, осуществляющий мониторинг, который собирает значения системных или прикладных метрик и отсылает собранные значения демону carbon для хранения и визуализации. Метрики в Graphite имеют простые, иерархические имена, похожие на пути в файловых системах и отличающиеся лишь тем, что в качестве разделителя иерархий используется точка, а не слэш или обратный слэш. Carbon берет любое допустимое имя и для каждой метрики создает файл whisper, в котором хранятся точки данных этой метрики. Файлы \code{whisper} хранятся в каталоге данных \code{carbon} в виде иерархической файловой системы, отражающей иерархию деления точками на части имени каждой метрики, Следовательно servers.www01.cpuUsage отображается, например, в .../servers/www01/cpuUsage.wsp.

Когда клиентское приложение хочет отправить в Graphite значения точек данных, оно должно установить с carbon обычно через порт 2003 [2] соединение TCP. Все сообщения поступают только со стороны клиента: carbon ничего не пересылает через это соединение. Пока соединение остается открытым, клиент, когда это необходимо, отсылает значения точек данных в простом текстовом формате. Формат представляет собой одну текстовую строку для каждого значения точки ввода, в которой указываются разделяемые пробелами имя, имеющее точки, значение данных и временная отметка, использующая формате времени UNIX. Например, клиент может послать следующее:

servers.www01.cpuUsage 42 1286269200
products.snake-oil.salesPerMinute 123 1286269200
[one minute passes]
servers.www01.cpuUsageUser 44 1286269260
products.snake-oil.salesPerMinute 119 1286269260

Если рассматривать в общем, то все, что carbon делает, это прослушивает поступающие данные в этом формате и с помощью whisper пытается сохранить их на диске настолько быстро, насколько это возможно. Далее мы обсудим некоторые конкретные особенности, используемые для обеспечения масштабируемости и достижения наилучшей производительности, которую мы можем получить на обычном жёстком диске.

7.3. Клиентская часть: Графики по запросу

Веб-приложение Graphite позволяет пользователям запрашивать различные графики с помощью простого интерфейса API, базирующегося на использовании URL. Параметры графика указываются в строке HTTP-запроса GET, а в ответ возвращается изображение в формате PNG. Например, с помощью URL

http://graphite.example.com/render?target=servers.www01.cpuUsage&
width=500&height=300&from=-24h

делается запрос графика размером 500×300 для метрики servers.www01.cpuUsage с данными за последние 24 часа. В действительности нужно указать только целевой параметр (т. е. только то, что надо отобразить — прим.пер.); остальные параметры являются необязательными и, если их не указывать, то будут использоваться значения, задаваемые по умолчанию.

В проекте Graphite поддерживается большое количество разных параметров выдачи изображений, а также функций обработки данных, которые можно указывать с помощью простых функционально понятных синтаксических правил. Например, нам нужен график скользящих средних значений, рассчитываемых по десяти точкам, для метрики, взятой из предыдущего примера:

target=movingAverage(servers.www01.cpuUsage,10)

Функции могут быть вложенными, что позволяет создавать составные выражения и выполнять сложные вычисления.

Ниже показан еще один пример, в котором приводится промежуточная сумма с нарастающим итогом продаж за день, изображающая поминутное значение метрик для каждого продаваемого продукта:

target=integral(sumSeries(products.*.salesPerMinute))&from=midnight

С помощью функции sumSeries вычисляется временной ряд, являющийся суммой значений каждой метрики, соответствующей образцу products.*.salesPerMinute. Затем с помощью функции integral рассчитывается промежуточная сумму с нарастающим итогом с интервалом в одну минуту. Из этих примеров несложно понять, как можно создать пользовательский веб-интерфейс для просмотра и обработки графиков. Пакет Graphite поставляется с своим собственным конструктором пользовательского интерфейса Composer UI, показанным на рис.7.3, в котором, когда пользователь выбирает из меню имеющиеся возможности, то используется язык JavaScript, который изменяет параметры URL запрашиваемого графика.

Рис.7.3: Внешний вид конструктора интерфейсов пакета Graphite

7.4. Панели управления

С самого своего появления пакет Graphite использовался как инструмент для создания панелей управления на основе веб-интерфейса. Интерфейс URL API делает такое использование вполне естественным. Создать панель управления столь же просто, как добавить теги в HTML-страницу при ее создании, например:

<img src="../http://graphite.example.com/render?parameters-for-my-awesome-graph">

Однако, не всем нравится вручную прописывать адреса URL, поэтому в конструкторе пользовательских интерфейсов пакета Graphite для создания графиков предлагается метод «выбери точку и щелкни», с помощью которого вы можете просто скопировать и вставить адрес URL. Если объединить этот метод с еще одним инструментом для быстрого создания веб-страниц (например, страниц wiki), то это позволит даже не сильно подкованным в техническом смысле пользователям сравнительно легко собирать свои собственные панели управления.

7.5. Очевидное узкое место

Как только мои пользователи начали создавать панели управления, у Graphite быстро появились проблемы с производительностью. Я проанализировал журналы веб-сервера с тем, чтобы увидеть, какие запросы тянут систему на дно. Абсолютно очевидно, что проблема была в запросе огромного количества графиков. Веб-приложение сильно загружало центральный процессор, постоянно рисуя графики. Я заметил, что было масса идентичных запросов и виной тому были панели управления.

Представьте себе, что у вас есть панель управления с десятью графиками, и страница обновляется каждую минуту. Каждый раз, когда пользователь открывает в браузере панель управления, Graphite должен обработать за минуту еще на 10 запросов больше. Это быстро становится затратным.

Простым решением было рисовать каждый график только один раз, а затем каждому пользователю отправлять его копию. Веб-фреймворк Django (на котором был построен проект Graphite) предоставляет замечательный механизм кэширования, в котором можно использовать различные серверные решения, например, memcached. Memcached [3] является, в сущности, хеш-таблицей, предоставляемой в виде сетевого сервиса. Клиентские приложения могут получать и устанавливать пары «ключ-значение» точно также, как и в обычной хеш-таблице. Главное преимущество в использовании memcached состоит в том, что результат затратного запроса (например, на визуализацию графика) может быть очень быстро запомнен и позднее может быть найден при обработке последующих запросов. Чтобы навсегда избежать ситуаций с возвратом устаревших графиков, memcached можно сконфигурировать таким образом, чтобы время хранения кэша истекало в течение очень короткого периода времени. Даже если это будет всего лишь несколько секунд, для Graphite это огромное облегчение, поскольку дублирующие запросы распространены очень сильно.

Другим типичным случаем, при котором создается большое количество запросов на визуализацию, является ситуация, когда пользователь настраивает параметры отображения и использует функции в конструкторе пользовательского интерфейса. Каждый раз, когда пользователь что-то изменяет, Graphite должен перерисовать график. В каждом запросе указываются одни и те же данные, поэтому имеет смысл в memcached также поместить данные, которые использовались для визуализации. Это позволяет пользовательскому интерфейсу продолжать реагировать на запросы пользователей, поскольку этап выборки данных пропускается.

7.6. Оптимизация ввода/вывода

Представьте себе, что у вас есть 60000 метрик, которые вы отправляете на ваш сервер Graphite, и для каждой из этих метрик есть одно значение точки данных в течение одной минуты. Вспомните, у каждой метрики в файловой системе есть свой собственный файл whisper. Это означает, что carbon должен каждую минуту выполнять по одной операции записи в 60000 различных файлов. Пока carbon может делать запись в один файл за миллисекунду, он будет справляться с ситуацией. Следующая ситуация отстоит от текущей не так уж далеко, но давайте предположим, что каждую минуту вы должны обновлять 600000 метрик, или ваши метрики обновляются каждую секунду, или, возможно, вы просто не можете себе позволить использовать достаточно быструю память. Независимо от ситуации, предположим, скорость поступающих значений точек данных превышает скорость операций записи, которую может поддерживать ваша память. Как справиться с такой ситуацией?

Большинство современных жёстких дисков имеет медленное время позиционирования [4], т.е. имеет большую задержку между выполнением операций ввода/вывода в двух различных местах на диске в сравнении с записью непрерывной последовательности данных. Это означает, что, чем больше мы делаем непрерывных записей, тем большую производительность мы получаем. Но если у нас тысячи файлов, в которые необходимо часто делать запись, и каждая запись очень мала (одно значение точки данных в whisper занимает всего 12 байтов), то жёсткие диски, несомненно, будут тратить большую часть времени на позиционирование.

Если исходить из условия, что скорость выполнения операций записи имеет сравнительно низкую планку, единственный способ сделать так, чтобы пропускная способность наших значений точек данных превышала эту планку, это записывать значения нескольких точек данных в одной операции записи. Это возможно, поскольку whisper записывает подряд идущие значения точек данных на диск непрерывно друг за другом. Поэтому я добавил в whisper функцию update_many, которая берет список значений точек данных для одной метрики и собирает значения подряд идущих точек данных в одну операцию записи. Даже хотя размер каждой записи становится больше, разница во времени записи десяти значений точек данных (120 байтов) в сравнении с записью значения одной точки данных (12 байтов) незначительна. Можно взять достаточно много значений точек данных прежде, чем размер каждой записи начнет вносить существенную задержку.

Затем я реализовал в carbon механизм буферизации данных. Каждое поступающее значение точки данных отображается в очереди, имеющей имя метрики, и, затем, помещается в эту очередь. Еще один поток циклически проходит по всем очередям и для каждой из них выбирает все значения точек данных и с помощью функции update_many записывает их в соответствующий файл whisper. Если вернуться к приведённому выше примеру, когда у нас есть 600000 метрик, обновляющихся каждую минуту, и наша память может справиться только с одной записью в миллисекунду, то в среднем в каждой очереди может быть приблизительно до 10 значений точек данных. Единственным ресурсом, которым мы за это платим, является память, которой хватает с избытком поскольку каждая точка данных занимает всего несколько байтов.

Эта стратегия позволяет динамически накапливать в буфере столько точек данных, сколько необходимо для поддержки скорости поступающих точек данных, которая может превышать скорость операций ввода/вывода и с которой может справиться ваша память. Хорошим преимуществом такого подхода является то, что он повышает степень устойчивости к временным замедлениям ввода/вывода. Если системе нужно выполнить другую работу по вводу/выводу, не связанную с Graphite, то, скорее всего, скорость операций записи уменьшится, что просто приведет к росту очередей в carbon. Чем больше очереди, тем больше размер записи. Поскольку общая пропускная способность значений точек данных равна скорости операций записи, умноженной на средний размер каждой записи, carbon способен держаться до тех пор, пока для очередей будет достаточно памяти. Механизм очередей в carbon изображён на рис.7.4.

Рис.7.4: Механизм очередей в Carbon

7.7. Все это в режиме реального времени

Буферизация значений точек данных была замечательным способом оптимизировать ввод/вывод в carbon, но через не очень продолжительное время мои пользователи заметили довольно тревожный побочный эффект. Снова вернемся к примеру с 600000 метриками, обновляющимися каждую минуту, и, предположим, что наша память может справиться лишь с 60000 операциями записи в минуту. Это означает, что у нас возникает ситуация, когда в любой заданный момент времени данные будут находиться в очередях carbon приблизительно по 10 минут. Для пользователя это означает, что в графиках, которые они запросили из веб-приложения Graphite, будут отсутствовать данные за последние 10 минут. Это плохо!

К счастью, решение является довольно простым. Я добавил в carbon слушающий сокет, в котором предлагается интерфейс для работы с очередью, предоставляющий доступ к точкам данным, запомненным в буфере, а затем изменил веб-приложение Graphite так, чтобы использовать этот интерфейс каждый раз, когда нужно найти данные. Затем веб-приложение комбинирует значения точек данных, полученных из carbon, со значениями точек данных, считываемых с диска и, вуаля, графики строятся в режиме реального времени. Конечно, в нашем примере значения точек данных обновляются раз в минуту и, следовательно, не совсем «в реальном времени», но тот факт, что каждое значение точки данных мгновенно появляется на графе сразу, как только оно поступило в carbon, считается режимом реального времени.

7.8. Ядра, кэширование и катастрофические отказы

К настоящему моменту, вероятно, уже очевидно, что ключевой характеристикой производительности, от которой зависит собственная производительность Graphite, является задержка ввода/вывода. До сих пор мы предполагали, что в нашей системе стабильно низкая задержка ввода/вывода, составляющая в среднем около одной миллисекунды на запись, но это серьезное предположение, требующее несколько более глубокого анализа. Большинство жёстких дисков просто не настолько быстры; даже когда десятки дисков объединены в RAID-массив, есть очень большая вероятность, что задержка при случайном доступе будет больше одной миллисекунды. Тем не менее, если вы попробуете и проверите, насколько быстро даже старый ноутбук мог бы записать целый килобайт данных на диск, вы обнаружите, что системный вызов записи возвращает управление гораздо быстрее, чем 1 миллисекунда. Почему?

Всякий раз, когда характеристики производительности программ оказываются противоречивыми или неожиданными, в этом виноваты, как правило, либо буферизация, либо кэширование. В данном случае мы имеем дело с обеими причинами. Системный вызов записи технически не выполняет запись ваших данных на диск, он просто помещает данные в буфер, который позже ядро запишет на диск. Поэтому вызов записи обычно возвращается так быстро. Даже после того, как буфер был записан на диск, он часто остается в кеш-памяти для последующих операций чтения. Для обеих этих функций, буферизации и кеширования, конечно, требуется память.

Разработчики ядра, будучи умные людьми, решили, что будет хорошо, если использовать любую свободную память пользовательского пространства, а не выделять память напрямую. Это оказывается чрезвычайно полезным способом повышения производительности и это также объясняет, почему независимо от того, сколько к системе вы добавляете памяти, количество «свободной» памяти после выполнения небольшого количества операций ввода/вывода в конечном итоге стремится к нулю. Если ваши приложения, размещенные в пользовательском пространстве, не используют эту память, то ядро, вероятно, использует. Недостатком этого подхода является то, что эта «свободная» память может быть забрана из ядра в тот момент, когда приложение пользовательского пространства решит, что ему для собственных нужд требуется выделить больше памяти. У ядра нет выбора, кроме как отказаться от памяти, потеряв все, возможно, и буферы, которые там были.

Что это всё означает для пакета Graphite? Мы просто подчеркнули зависимость carbon от стабильно низкой задержки ввода/вывода, и мы также знаем, что системный вызов записи возвращает управление быстро только потому, что данные просто копируются в буфер. Что происходит, когда ядру не хватает памяти для продолжения буферизации записи? Запись становится синхронной и, следовательно, ужасно медленной! Это приводит к резкому снижению скорости операций записи в carbon, что ведет в carbon к росту очередей, съедающих еще больше памяти, которой еще больше не хватает ядру. В конце концов, такая ситуация обычно приводит к тому, что в carbon заканчивается память или что сердитый сисадмин убивает процесс.

Чтобы избежать катастрофы подобного рода, я добавил к carbon несколько возможностей, в том числе конфигурируемые ограничения на количество значений точек данных, которые могут быть в очереди, и ограничения, определяющие насколько быстро в {whisper могут выполняться различные операции. Эти возможности могут защитить carbon от потери управления, но вместо этого могут привести к менее суровым последствиям, например, к пропаданию некоторых значений точек данных и к отказу от приема новых значений точек данных. Однако, правильные значения для этих настроек зависят от системы и для их настройки требуется значительное количество проб. Эти возможности полезны, но они не решают данную проблему принципиально. Для этого нам понадобится больше оборудования.

7.9. Кластеризация

Объединить несколько серверов Graphite так, чтобы они казались одной системой, с точки зрения пользователя не так уж и трудно, по крайней мере, при наивной реализации. Взаимодействие веб-приложения с пользователем в первую очередь состоит из двух операций: поиск метрик и выборка значений для точек данных (как правило, в виде графика). Операции поиска и выборки из веб-приложения спрятаны в библиотеке, которая абстрагирует их реализацию от остальной части кода, причем они также доступны через обработчики запросов HTTP запроса для того, чтобы их было проще вызывать дистанционно.

Операция поиска find ищет в локальной файловой системе whisper совпадение с образцом, заданным пользователем точно также, как общая файловая система ищет совпадение файлов, например, *.txt, с указанным расширением. Поскольку это древовидная структура, результат, возвращаемый операцией find, является коллекцией объектов Node (Узел), каждый из которых является производным подкласса Branch (Ветка) или Leaf (Лист) класса Node. Каталоги соответствуют узлам-веткам, а файлы whisper - узлам-листьям. Такой уровень абстракции упрощает поддержку хранилищ данных, расположенных ниже, в том числе файлов RRD [5] и заархивированных файлов \code{whisper}.

В интерфейсе leaf определяется метод выборки fetch, реализация которого зависит от типа листа. В случае, если это файл whisper, он является просто тонкой оболочкой вокруг собственной функции fetch библиотеки whisper. Когда была добавлена поддержка кластеризации, функция find была расширена так, чтобы она могла выполнять дистанционные вызовы функции find через протокол HTTP к другим серверам Graphite, указанным в конфигурации веб-приложения. Данные об узлах, содержащиеся в результатах этих HTTP-запросов, предоставляются в виде объектов RemoteNode, которые пригодны для использования с интерфейсами Node, Branch и Leaf. Это делает кластеризацию прозрачной для остальной части кода веб-приложения. Метод fetch для удалённых узлов-листьев выполняется как еще один запрос HTTP для получения значений точек данных с сервера Graphite, где расположен этот узел.

Все эти вызовы выполняются между веб-приложениями точно также, как они выполняются клиентом, за исключением лишь одного параметра, указывающего, что операция должна выполняться локально, а не перераспределяться по кластеру. Когда в веб-приложение приходит запрос на визуализацию графика, оно выполняет операцию find с тем, чтобы найти запрашиваемые метрики, и вызывает операцию fetch для каждой из них для того, чтобы выбрать значения их точек данных. Это работает во всех случаях: когда данные находятся на локальном сервере, на удалённом сервере, или на локальном и удаленном серверах одновременно. Если сервер выходит из строя, то сравнительно быстро возникает состояние таймаута для дистанционных вызовов и сервер помечается как необслуживаемый на короткий промежуток времени, в течение которого к нему не будет запросов. С точки зрения пользователя, все данные, которые были на потерянном сервере, будут отсутствовать на их графиках, если, конечно, эти данные не были продублированы на другом сервере в кластере.

7.9.1. Краткий анализ эффективности кластеризации

Самой дорогостоящей частью запроса графика является отрисовка графика. Каждая отрисовка выполняется одним сервером, поэтому добавление большего количества серверов эффективно увеличивает производительность отрисовки графиков. Однако тот факт, что многие запросы перераспределяют вызовы find на любой другой сервер в кластере, означает, что наша схема кластеризации используется в перераспределении нагрузки, касающейся клиентских запросов, а не рассредоточения нагрузок. Однако, то, чего мы добились на данный момент, является эффективным способом распределения серверных нагрузок, поскольку каждый экземпляр carbon работает независимо от других. Это хороший первый шаг, поскольку в большинстве случаев серверная сторона становится узким местом задолго до того, как это происходит с клиентскими обращениями, но очевидно, что с помощью этого подхода не удастся выполнить горизонтальное масштабирование клиентских обращений.

Что сделать масштабирование клиентских обращений более эффективным, необходимо сократить количество дистанционных запросов find, делаемых веб-приложением. Снова простейшим решением является кеширование. Точно также, как memcached уже используется для кеширования значений точек данных и для нарисованных графиков, этот же самый подход можно использовать для кеширования результатов запросов find. Поскольку маловероятно, что местоположение метрик меняется часто, их можно хранить в кэше достаточно долго. Однако если настройка таймаута кеширования результатов операций find окажется слишком длительной, то новые метрики, которые добавлены в иерархию, могут не сразу стать доступными для пользователя.

7.9.2. Хранение метрик в кластере

В кластере веб-приложение Graphite сравнительно однородное в том смысле, что оно выполняет на каждом сервере одну и ту же работу. Однако, роль carbon может варьироваться от сервера к серверу в зависимости от того, какие данные отправляются на каждый экземпляр сервера. Часто есть много различных клиентов, посылающих данные в carbon, поэтому было бы весьма хлопотно настраивать конфигурацию каждого клиента в соответствие с компоновкой кластера Graphite. Метрики, используемые в обычных приложениях, могут отсылаться на один сервер carbon, а метрики, используемые в бизнес-приложениях, - на несколько серверов carbon с целью обеспечения избыточности.

Чтобы упростить управление сценариями подобного рода, пакет Graphite поставляется с дополнительным инструментальным средством, называемым carbon-relay. Его работа сравнительно проста; он получает от клиентов данные метрики точно также, как и стандартный демон carbon (а точнее, carbon-cache), но вместо того, чтобы запоминать данные, он применяет к именам метрик набор правил с тем, чтобы определить, на какой сервер carbon-cache перенаправить данные. Каждое правило состоит из регулярного выражения и списка серверов, на которые можно направлять данные. Для каждого значения точки данных по порядку применяются правила и используется первое правило, регулярное выражение в котором совпадет с именем метрики. Таким образом всё, что нужно клиенту сделать, это отослать данные на carbon-relay и попадут на надлежащие серверы.

В каком-то смысле carbon-relay предоставляет функциональные возможности репликации, хотя более точно они должны называться дублированием входных данных, поскольку не решаются вопросы синхронизации. Если сервер временно недоступен, то будут отсутствовать значения точек данных за тот период, в течение которого он был недоступен, но все остальное будет функционировать нормально. Есть административные скрипты, которые позволяют системному администратору управлять процессом ресинхронизации.

7.10. Размышления о проекте

Мой опыт работы с пакетом Graphite подтвердил мое убеждение, что масштабируемость имеет очень мало общего с низким уровнем производительности, а является результатом общего устройства проекта. По пути я сталкивался со многими узкими местами, но каждый раз я искал решения в преобразовании проекта, а не в повышении производительности. Я много раз спрашивал, почему я написал Graphite на языке Python, а не на Java или на C++, и я всегда отвечал, что я до сих пор не гонюсь за той производительностью, что мог бы предложить другой язык. В работе [Knu74], Дональд Кнут сказал знаменитую фразу, что преждевременная оптимизация есть корень всех зол. Пока мы считаем, что наш код будет продолжать развиваться нетривиальными способами, любая оптимизация [6] в некотором смысле преждевременна.

Одной из самых сильных и самых слабых сторон Graphite является то, что он, в действительности, очень мало «проектировался» в традиционном смысле слова. По большому счету Graphite развивался постепенно и брал препятствия по мере того, как возникали проблемы. Много раз препятствия можно было предвидеть и различные упреждающие решения казались естественными. Однако, было бы лучше избегать решения проблем, с которыми вы еще не столкнулись, даже если кажется, что они вскоре возникнут. Причина в том, что вы можете узнать гораздо больше от внимательного изучения фактических неудач, чем при теоретизировании о превосходной стратегии. Решение проблемы обуславливается как эмпирическими данными, которые у нас есть под рукой, так и нашими собственными знаниями и интуицией. Я узнал, что сомнения в достаточности собственных знаний может заставить вас посмотреть более тщательно на ваши эмпирические данные.

Например, когда я впервые написал whisper, я был убеждён, что он должен быть переписан на язык C с тем, чтобы увеличить скорость, и что код на языке Python мог бы служить исключительно в качестве прототипа. Если бы тогда я не был в цейтноте, я бы, скорее всего, полностью исключил применение языка Python. Однако, оказывается, что ввод/вывод становится узким местом гораздо раньше, чем процессор, и что на практике меньшая эффективность языка Python вряд ли вообще имеет значение.

Как я уже говорил, эволюционный подход также является большой слабостью проекта Graphite. Интерфейсы, как оказалось, плохо поддаются постепенной эволюции. Хороший интерфейс для максимальной предсказуемости должен быть согласованным и должен отвечать соглашениям об использовании. По этому показателю, интерфейс URL API в Graphite является, на мой взгляд, в настоящее время неудовлетворительным. С течением времени добавлялись параметры и функции, иногда образовывались небольшие острова согласованности, но в целом общей согласованности не хватает. Единственный способ решить такую проблему является переход к версиям интерфейсов, но здесь тоже есть свои недостатки. Как только разрабатывается новый интерфейс, от старого интерфейса становится трудно избавиться и он сохраняется повсюду как эволюционный багаж, похожий на аппендикс у человека. Это может казаться достаточно безобидным, пока однажды у вашего кода этот аппендицит не воспалится (т.е. Не возникнет ошибка, связанная со старым интерфейсом) и вам придется прибегнуть к операции. Если бы я на ранней стадии должен был изменить в Graphite только одну вещь, то я бы уделил гораздо больше внимания разработке внешних интерфейсов API, обдумывая их заранее, а не собирая их по крупицам.

Другим аспектом Graphite, который вызывает некоторое разочарование, является ограниченная гибкость иерархической модели именования метрик. Хотя она довольно проста и очень удобна в большинстве случаев, с ее помощью становится трудно и даже невозможно делать некоторые сложные запросы. Когда я впервые подумал о создании Graphite, я с самого начал знал а, что для создания графиков хотел иметь интерфейс API на основе URL, который мог бы редактировать человек [7]. Хотя я до сих пор рад, что в Graphite сегодня есть эта возможность, я боюсь, что такое требование заставляет ограничиться в API исключительно простым синтаксисом, который делает сложные выражения громоздкими. Иерархия делает проблему определения «первичного ключа» для метрики достаточно простой, поскольку для узла в дереве первичным ключом, по существу, является путь. Недостаток состоит в том, что все описательные данные (т.е. данные в столбцах) должны встраиваться непосредственно в пути. Возможное решение состоит в поддержке иерархической модели и добавлении отдельной базы метаданных, которая с помощью специального синтаксиса предоставит более усовершенствованные способы выборки метрик.

7.11. Переход в статус открытого кода

Оглядываясь на эволюцию Graphite, я все еще удивляюсь тому, как далеко он зашел как проект, и как далеко он завел меня как программиста. Он начался, как любимое занятие, в котором был всего лишь несколько сотен строк кода. Движок отрисовки стартовал в качестве эксперимента просто для того, чтобы я мог увидеть, смогу ли написать его. whisper был написан в течение выходных от отчаяния для решения непреодолимой проблемы к критической дате запуска. carbon переписывался столько раз, что сложно вспомнить. Когда в 2008 году мне только что разрешили выпустить Graphite под лицензией открытого исходного кода, я не ожидал, что будет отклика. Через несколько месяцев Graphite был упомянут в статье в CNET, что было замечено в Slashdot и проекта вдруг стал популярным и его популярность продолжается до сих пор. Сегодня существуют десятки крупных и средних компаний, использующих Graphite. Сообщество достаточно активно и продолжает расти. Проекту далеко до завершения, есть много классной экспериментальной работы, которая выполняется, и это поддерживает интерес к работе и открывает большие перспективы.

Примечания

  1. http://launchpad.net/graphite
  2. Есть еще один порт, через который можно пересылать сериализированные объекты, что более эффективно, чем пересылка в простом текстовом формате. Это необходимо только для очень высоких объёмов трафика.
  3. http://memcached.org
  4. Твердотельные накопители в сравнении обычными жёсткими дисками обычно имеют очень большую скорость позиционирования.
  5. Файлы RRD в действительности являются узлами-ветками, поскольку в них могут указываться несколько источников данных; источники данных RRD являются узлами-листьями.
  6. Кнут, в частности, имел в виду низкоуровневую оптимизацию кода, а не макрооптимизацию, такую как улучшение проекта.
  7. Это требует, чтобы сами графики были открыты. Любой может просто посмотреть на URL графика, чтобы понять или изменить его.

8.1. Введение

Hadoop1 является распределенной файловой системой, также включающей в свой состав фреймворк для проведения анализа и преобразований очень больших объемов данных с использованием парадигмы MapReduce [DG04]. Хотя интерфейс HDFS и проектировался по аналогии с интерфейсами файловых систем из состава Unix, разработчики пожертвовали точностью следования стандартам в угоду повышению производительности используемых приложений.

Важной характеристикой файловой системы Hadoop является распределение данных и вычислительных ресурсов между многими (тысячами) узлов, а также выполнение предусмотренных приложениями вычислений параллельно с доставкой необходимых данных. Кластер Hadoop позволяет масштабировать вычислительные ресурсы, емкость устройств хранения данных и пропускную способность каналов для осуществления операций ввода/вывода путем простого добавления приобретаемых серверов. Кластеры Hadoop в компании Yahoo! в совокупности состоят из 40000 серверов и хранят 40 петабайт данных приложений, при этом самый большой кластер состоит из 4000 серверов. Около ста других организаций со всего мира заявляют об использовании Hadoop.

Файловая система HDFS хранит метаданные и данные приложений отдельно. Как и другие распределенные файловые системы, такие, как PVFS [CIRT00], Lustre2 и GFS [GGL03], HDFS хранит метаданные на выделенном сервере, называемом сервером метаданных (NameNode). Данные приложений хранятся на других серверах, называемых серверами данных приложений (DataNode). Все серверы взаимодействуют друг с другом посредством протоколов, основывающихся на протоколе TCP. В отличие от файловых систем Lustre и PVFS, используемые HDFS серверы данных приложений (DataNode) не полагаются с целью повышения надежности хранения данных на такие механизмы их защиты, как RAID. Напротив, аналогично файловой системе GFS, содержимое файла распределяется между множеством серверов данных приложений для его надежного хранения. Гарантируя надежность хранения данных, применяемая стратегия позволяет использовать преимущество умножения пропускной способности канала передачи данных, а также в случае ее использования появляется возможность проводить вычисления параллельно с доставкой необходимых данных.

8.2. Архитектура

8.2.1. Сервер метаданных NameNode

Пространство имен файловой системы HDFS представлено иерархией файлов и директорий. Файлы и директории представлены структурами inode на сервере метаданных (NameNode). Структуры inode содержат такие атрибуты файла, как права доступа, метки времени модификации и последнего доступа, квоты для пространства имен и дискового пространства. Содержимое файла разделяется на большие блоки (обычно размером в 128 мегабайт, но этот размер также может задаваться пользователем для каждого из файлов) и каждый такой блок файла независимо копируется на множество серверов данных приложений. Текущая архитектура предусматривает возможность использования одного сервера метаданных (NameNode) для каждого кластера. Кластер может содержать тысячи серверов данных приложений и обслуживать десятки тысяч клиентов HDFS, так как каждый сервер данных приложений позволяет выполнять множество приложений одновременно.

8.2.2. Образ и журнал

Структуры inode и список блоков, являющиеся метаданными системы имен, называются образом. Сервер метаданных (NameNode) хранит образ пространства имен полностью в оперативной памяти. Постоянная копия образа, хранящаяся в локальной файловой системе сервера метаданных называется контрольной точкой. Данные изменений HDFS, сохраняемые в локальной файловой системе сервера метаданных перед произведением самих изменений, называются журналом. Данные о расположении скопированных блоков файлов не являются постоянной частью файла контрольной точки.

Каждая инициированная клиентом транзакция находит свое отражение в журнале, при этом изменения файла журнала принудительно записываются на диск перед отправкой подтверждения клиенту. Файл контрольной точки никогда не изменяется по инициативе сервера метаданных; новый файл записывается тогда, когда контрольная точка создается в ходе перезагрузки, при запросе от администратора или с участием сервера файлов контрольных точек (CheckpointNode), описанного в следующем разделе. В ходе запуска сервера метаданных происходит инициализация образа пространства имен с использованием файла контрольной точки, после чего в образ вносятся изменения из журнала. Новый файл контрольной точки и пустой журнал записываются в файловую систему сервера метаданных перед тем, как он начинает обслуживать клиентов.

Для повышения длительности хранения данных резервные копии файлов контрольной точки и журнала обычно хранятся на множестве независимых локальных разделов и на удаленных NFS-серверах. Первая мера предосторожности предотвращает потерю данных в случае выхода из строя отдельного раздела, вторая - в случае выхода из строя всего сервера. Если сервер метаданных сталкивается с ошибкой при копировании файла журнала в одну из директорий для его хранения, он автоматически исключает эту директорию из списка директорий. В том случае, если ни одна из директорий для хранения файлов не доступна, сервер метаданных автоматически прекращает свою работу.

Сервер метаданных использует систему с поддержкой программных потоков и обрабатывает запросы от множества клиентов одновременно. Операция сохранения данных транзакции на диск становится узким местом, так как всем потокам приходится ожидать завершения процедуры синхронной записи, инициированной одним из них. На самом деле, для оптимизации этого процесса сервер метаданных создает очередь из множества транзакций. Когда один из потоков сервера метаданных инициирует операцию записи, все ожидающие в очереди транзакции, выполняются вместе. Остающиеся потоки должны только проверить, были ли сохранены данные в ходе транзакций и не должны осуществлять принудительную запись.

8.2.3. Сервер данных приложений DataNode

Каждая копия блока файла на сервере данных приложений представлена двумя файлами в локальной файловой системе. Первый файл содержит сами данные, а второй - метаданные блока, включающие в себя контрольные суммы для блока данных и метку времени, относящуюся к моменту генерации блока. Размер файла данных равен используемому размеру блока и файл не занимает дополнительного дискового пространства для округления в строну повышения размера файла до размера блока таким образом, как это делается в традиционных файловых системах. Следовательно, если размер блока составляет половину установленного размера, он занимает объем локального диска, соответствующий только половине установленного размера блока.

Во время загрузки каждый сервер данных приложений соединяется с сервером метаданных и осуществляет операцию рукопожатия. Целью этой операции рукопожатия является проверка идентификатора пространства имен и версии программного обеспечения на сервере данных приложений. Если хотя бы одно из используемых сервером метаданных значений не совпадает со значением, используемым сервером данных приложений, сервер данных приложений автоматически прекращает свою работу.

Идентификатор пространства имен присваивается файловой системе во время форматирования. Этот идентификатор хранится на всех серверах кластера на постоянной основе. Серверы с различными идентификаторами пространств имен не смогут работать в рамках одного кластера, таким образом защищается целостность файловой системы. Только что инициализированному серверу данных приложений без идентификатора пространства имен разрешено входить в состав кластера и получать идентификатор пространства имен от него.

После рукопожатия сервер данных приложений регистрируется сервером метаданных. Серверы данных приложений постоянно хранят свои уникальные идентификаторы хранилищ. Идентификатор хранилища является внутренним идентификатором сервера данных приложений, который позволяет идентифицировать его даже после перезагрузки и присвоения ему другого IP-адреса или номера порта. Идентификатор хранилища присваивается серверу данных приложений при регистрации сервером метаданных в первый раз и никогда не изменяется после этого.

Сервер данных приложений сообщает о принадлежащих ему блоках данных серверу метаданных путем отправки отчетов о блоках. Каждый отчет содержит идентификатор блока, метку времени генерации блока и размер копии блока на сервере. Первый отчет о блоках отправляется немедленно после регистрации сервером метаданных. Последующие отчеты о блоках отправляются каждый час и предоставляют серверу метаданных актуальную информацию о расположении копий блоков в рамках кластера.

В процессе работы серверы данных приложений отправляют сообщения о состоянии (heartbeats) серверу метаданных для подтверждения того, что сервер данных приложений работает и копии блоков, хранящиеся на нем, доступны. Стандартным интервалом отправки сообщений о состоянии является трехсекундный интервал. В том случае, если сервер метаданных не получает сообщений состояний от сервера данных приложений в течение десяти минут, он считает, что сервер данных приложения не функционирует и копии блоков данных, хранившиеся на нем, недоступны. После этого сервер метаданных планирует операции создания новых копий хранившихся на отключенном сервере блоков на других серверах данных приложений.

Сообщения о состоянии серверов данных приложений также содержат информацию об общем объеме устройств хранения, процентном отношении использованного дискового пространства и количестве передач данных, осуществляемых в текущий момент. Эти статистические данные используются сервером метаданных для принятия решений о резервировании блоков и балансировке нагрузки.

Сервер метаданных не отправляет запросы напрямую серверам данных приложений. Он использует ответы на сообщения о состоянии для отправки инструкций серверам данных приложений. Инструкции включают в себя команды для копирования блоков на другие серверы, удаления локальных копий блоков, повторной регистрации, немедленной отправки отчетов о блоках и завершения работы сервера.

Эти команды особенно важны для поддержания целостности системы, следовательно критически важно поддерживать постоянную отправку сообщений о состоянии даже в крупных кластерах. Сервер метаданных может обрабатывать тысячи сообщений о состоянии в секунду без нарушения процесса выполнения других операций.

8.2.4. Клиент HDFS

Пользовательские приложения получают доступ к файловой системе с помощью клиента HDFS, библиотеки, экспортирующей интерфейс файловой системы HDFS.

Как и большинство традиционных файловых систем, HDFS поддерживает операции чтения, записи и удаления файлов, а также операции создания и удаления директорий. Пользователь описывает файлы и директории с помощью путей из пространства имен. Пользовательскому приложению не нужно беспокоиться о том, что метаданные и данные файлов из файловой системы хранятся на разных серверах или о том, что блоки имеют множество копий.

Когда приложение читает файл, клиент HDFS в первую очередь запрашивает у сервера метаданных список серверов данных приложений, хранящих копии блоков данных необходимого файла. Список сортируется на основании дистанции до клиента в рамках топологии сети. Клиент соединяется напрямую с сервером данных приложений и запрашивает передачу необходимого блока. Когда клиент осуществляет запись, он в первую очередь требует от сервера метаданных выбора серверов данных приложений для хранения копий первого блока файла. Клиент организует канал между несколькими серверами и отправляет данные. Когда первый блок передан, клиент запрашивает выбор следующих серверов данных приложений для хранения копий следующего блока. Организуется новый канал и клиент отправляет данные следующего блока. Выбор серверов данных приложений для каждого блока вероятнее всего будет различным. Взаимодействия между клиентом, сервером метаданных и серверами данных приложений отражены на Рисунке 8.1.

Клиент HDFS создает новый файл
Рисунок 8.1: Клиент HDFS создает новый файл

В отличие от традиционных файловых систем, HDFS предоставляет API, позволяющий выявить расположения блоков данных файлов. Это обстоятельство позволяет таким приложениям, как фреймворк MapReduce планировать выполнение задач на тех серверах, где располагаются необходимые данные, тем самым повышая скорость чтения данных. Обычно файлы подвергаются трехкратной репликации. В случае работы с важными файлами или файлами, доступ к которым осуществляется очень часто, повышенная степень репликации повышает устойчивость к отказам и скорость чтения данных.

8.2.5. Сервер файлов контрольных точек CheckpointNode

Сервер метаданных в HDFS в дополнение к его основным задачам обработки клиентских запросов, может работать в одном из двух других режимов, выполняя функции сервера файлов контрольных точек (CheckpointNode) или сервера резервных копий (BackupNode). Режим работы устанавливается в процессе загрузки сервера.

Сервер файлов контрольных точек периодически комбинирует данные из существующего файла контрольной точки с данными из журнала для создания нового файла контрольной точки и пустого журнала. Сервер файлов контрольных точек обычно работает на отдельном от сервера метаданных узле, так как имеет аналогичные требования к оперативной памяти. Он загружает текущие файлы контрольной точки и журнала с сервера метаданных, объединяет их и возвращает новый файл контрольной точки серверу метаданных.

Периодическое создание файлов контрольных точек является одним из методов защиты метаданных файловой системы. Система может начать работу с наиболее поздним файлом контрольной точки в том случае, если все остальные постоянные копии образа пространства имен и журнала недоступны. Создание файла контрольной точки также позволяет серверу метаданных очистить журнал в тот момент, когда загружается новый файл контрольной точки. Кластеры HDFS работают в течение длительных промежутков времени без перезагрузок, при этом файл журнала также постоянно растет в течение этих промежутков времени. Если файл журнала достигнет очень большого объема, повысится вероятность его потери или повреждения. Также, очень большой файл журнала увеличивает период времени, требуемый для перезагрузки сервера метаданных. При работе большого кластера обработка файла журнала с событиями за неделю происходит в течение часа. Хорошей практикой является ежедневное создание файла контрольной точки.

8.2.6. Сервер резервных копий BackupNode

Недавно была представлена возможность использования сервера резервных копий (BackupNode) в HDFS. Как и в случае сервера файлов контрольных точек, сервер резервных копий позволяет периодически создавать файлы контрольных точек, но в дополнение к этому он поддерживает в оперативной памяти актуальный образ пространства имен файловой системы, который может быть синхронизирован с соответствующим образом сервера метаданных.

Сервер резервных копий принимает поток транзакций из пространства имен в форме файлов журнала от активного сервера метаданных, сохраняет свою копию журнала и применяет эти транзакции по отношению к своему образу пространства имен в оперативной памяти. Сервер метаданных рассматривает сервер резервных копий как хранилище файлов журнала, аналогичное хранилищу файлов журнала в локальных директориях. В случае неработоспособности сервера метаданных, образ пространства имен в оперативной памяти сервера резервных копий и файл контрольной точки на его диске будут содержать актуальные данные состояния пространства имен файловой системы.

Сервер резервных копий может создать файл контрольной точки без загрузки файлов контрольной точки и журнала с активного сервера метаданных, так как он и так имеет в распоряжении актуальный образ пространства имен файловой системы в оперативной памяти. Это обстоятельство делает процесс создания файла контрольной точки сервером резервных копий более эффективным, так как ему требуется только сохранить образ пространства имен в свои локальные директории.

Сервер резервных копий может рассматриваться как сервер метаданных, работающий в режиме только для чтения. Он хранит все метаданные файловой системы за исключением расположений блоков данных файлов. Он может выполнять все функции обычного сервера метаданных, не связанные с модификацией пространства имен или доступом к информации о расположении блоков данных файлов. Использование сервера резервных копий позволяет использовать сервер метаданных без постоянного хранилища, делегируя ответственность за постоянное хранение состояния пространства имен серверу резервных копий.

8.2.7. Обновления и снимки файловой системы

В процессе обновлений программного обеспечения вероятность повреждения файловой системы в результате ошибок программного обеспечения или обслуживающего персонала возрастает. Целью создания снимков файловой системы HDFS является минимизация потенциальных рисков повреждения данных системы во время ее обновлений.

Механизм снимков позволяет системным администраторам сохранить состояние файловой системы в неизменном виде, поэтому в том случае, если обновление системы приведет к потере данных или их повреждению, будет возможность отката изменений, вызванных обновлением системы, и возвращения пространства имен и состояния данных файловой системы HDFS к тому виду, в каком они были во время создания снимка.

Снимок (который может существовать только в единственном экземпляре) создается по усмотрению администратора кластера в любой момент после запуска системы. При запросе создания снимка сервер метаданных сначала считывает данные из файлов контрольной точки и журнала, объединяя их в оперативной памяти. После этого он записывает новые файлы контрольной точки и пустого журнала в других директориях таким образом, чтобы старые файлы контрольной точки и журнала не подверглись изменениям.

Во время обмена данными сервер метаданных отправляет серверам данных приложений команду создания локальных снимков. Локальный снимок на сервере данных приложений не может быть создан с помощью репликации директорий с файлами данных, так как эта операция потребовала бы удвоения объема устройств хранения данных каждого сервера данных приложений кластера. Вместо этого каждый сервер данных приложений создает копию директории для хранения данных, содержащую жесткие ссылки на существующие блоки данных. Когда сервер данных приложений удаляет блок, он удаляет только жесткую ссылку, а при модификациях блока в ходе поступления данных используется техника копирования при записи. Таким образом, копии старых блоков остаются нетронутыми в старых директориях.

Администратор кластера может осуществить откат файловой системы HDFS к состоянию в момент создания снимка при перезагрузке системы. Сервер метаданных восстановит файл контрольной точки, сохраненный во время создания снимка. Серверы данных приложений восстанавливают ранее переименованные директории и инициируют фоновый процесс удаления копий блоков, созданных после создания снимка. После использования функции отката не остается возможности для возврата в предыдущее состояние. Администратор кластера может освободить место, занятое данными снимка, использовав команду удаления снимка; для снимков, созданных во время обновления программного обеспечения эта операция завершается после обновления системы.

Эволюция системы может повлечь изменения в формате файлов контрольной точки и журнала сервера метаданных или представления копий файлов блоков на серверах данных приложений. Версия представления данных идентифицирует метод представления данных и постоянно хранится в директориях для файлов сервера метаданных и серверов данных приложений. В ходе загрузки каждый сервер сравнивает версию представления данных, используемую программным обеспечением, с версией представления данных, хранящихся в директориях, и автоматически преобразует данные из представлений устаревших форматов в новые. Преобразование требует обязательного создания снимка после перезагрузки сервера с использованием программного обеспечения, поддерживающего новую версию представления данных.

8.3. Операции ввода/вывода и управление копиями блоков

Конечно же, задачей файловой системы является хранение данных в файлах. Для понимания того, как эта задача реализуется файловой системой HDFS, нам следует рассмотреть процесс чтения и записи, а также процесс управления блоками данных.

8.3.1. Чтение и запись файлов

Приложение добавляет данные в файловую систему HDFS, создавая новый файл и записывая данные в него. После того, как файл закрывается, записанные байты не могут быть изменены или удалены, за исключением случаев, когда новые данные могут быть добавлены в файл после его повторного открытия для дополнения. Файловая система HDFS реализует модель, в рамках которой может функционировать один записывающий и множество читающих данные процессов.

Клиент HDFS, открывающий файл для записи, получает файл в свое полное распоряжение; ни один из других клиентов не сможет осуществить запись в этот файл. Записывающий данные клиент периодически подтверждает актуальность процесса модификации файла, отправляя сообщения о состоянии серверу метаданных. Когда файл закрывается, блокировка записи другими процессами снимается. Длительность блокировки записи другими процессами ограничивается мягким и жестким лимитами времени. До того, как мягкий лимит времени истекает, для записывающего данные процесса гарантируется эксклюзивный доступ к файлу. Если мягкий лимит времени истечет и клиент не закроет файл и не подтвердит блокировку с помощью сообщения о состоянии, блокировка может быть установлена для другого клиента. Если по истечении жесткого лимита времени (длительностью в один час) клиент не подтвердит блокировку, файловая система HDFS посчитает, что клиент завершил свою работу и автоматически закроет файл вместо клиента, устранив блокировку. Блокировка файла записывающим данные процессом не запрещает другим клиентам читать файл; файл может читаться параллельно множеством процессов.

В файловой системе HDFS файл состоит из блоков. Когда требуется новый блок, сервер метаданных резервирует блок с уникальным идентификатором и создает список серверов данных приложений для хранения копий блока. Серверы данных приложений формируют канал для передачи данных в порядке, который минимизирует общую дистанцию от клиента до наиболее удаленного сервера данных приложений. Байты передаются в канал в виде последовательности пакетов. Байты, которые приложение записывает в файл, в первую очередь подвергаются буферизации на стороне клиента. После заполнения буфера пакета (обычно размером в 64 КБ) данные передаются в канал. Следующий пакет может быть передан в канал перед приемом подтверждения доставки предыдущих пакетов. Количество не доставленных пакетов ограничивается размером окна для пакетов на стороне клиента.

После того, как все данные записаны в файл HDFS, файловая система не предоставляет никаких гарантий, что данные будут доступны новым процессам, открывающим файл для чтения, до момента его закрытия. Если пользовательскому приложению требуется гарантия того, что данные будут доступны для чтения, оно может явно выполнить операцию hflush. После этого текущий пакет немедленно отправляется в канал для передачи данных и операция hflush будет ожидать того момента, когда все серверы данных приложений, использующие канал, подтвердят успешный прием пакета. После этого все данные, записанные перед выполнением операции hflush, будут гарантированно доступны для чтения.

Состояние канала передачи данных во время записи блока
Рисунок 8.2: Состояние канала передачи данных во время записи блока

В случае отсутствия ошибок создание блока происходит в течение трех стадий, так, как показано на Рисунке 8.2, иллюстрирующем канал передачи данных из трех серверов данных приложений (DN) и блока из пяти пакетов. На рисунке с помощью жирных линий изображены пакеты данных, с помощью штриховых - сообщения для подтверждения передачи, а с помощью обычных линий - управляющие сообщения для создания и закрытия канала. Вертикальные линии отображают активность клиента и трех серверов данных приложений, причем время отсчитывается сверху вниз. В течение промежутка времени от t0 до t1 длится стадия создания канала для передачи данных. В интервале времени от t1 до t2 - стадия передачи данных, где t1 является временем отправки первого пакета, а t2 - время приема подтверждения доставки последнего пакета. В данном случае при передаче пакета 2 используется операция hflush. Указатель использования операции hflush передается с данными пакета и не используется в составе отдельной операции. Последний интервал времени от t2 до t3 соответствует стадии закрытия канала передачи данных для этого блока.

В кластере из тысяч серверов выходы из строя серверов (чаще всего из-за выхода из строя устройства хранения данных) случаются ежедневно. Копии блоков, хранящиеся на сервере данных приложений, могут быть повреждены из-за неисправностей оперативной памяти, диска или сети. Файловая система HDFS генерирует и хранит контрольные суммы для каждого из блоков данных файла HDFS. Контрольные суммы сверяются клиентом HDFS во время чтения файла для установления факта любого повреждения, вызванного клиентом и серверами данных приложений, либо сетью. Когда клиент создает файл HDFS, он вычисляет последовательность контрольных сумм для каждого блока и отправляет их серверу данных приложений вместе с данными. Сервер данных приложений сохраняет контрольные суммы в отдельном от блока данных файле метаданных. Когда HDFS читает файл, контрольные суммы каждого блока доставляются клиенту. Клиент вычисляет контрольную сумму принятых данных и проверяет, совпадают ли полученные контрольные суммы с рассчитанными. Если контрольные суммы не совпадают, клиент сообщает серверу метаданных о поврежденной копии блока, после чего принимает другую копию блока от другого сервера данных приложений.

Когда клиент открывает файл для чтения, он получает список блоков и данные о размещении каждой из копий блоков от сервера метаданных. Данные о размещении каждого блока расположены в зависимости от их дистанции от сервера, на котором осуществляется чтение. При чтении содержимого блока клиент в первую очередь пробует принять наиболее близко расположенную копию. Если попытка чтения не удается, клиент попытается прочитать следующую копию из последовательности. Чтение может завершиться неудачей в случае, если целевой сервер данных приложений не доступен, сервер больше не хранит копию блока или копия блока считается поврежденной после сравнения контрольных сумм.

Файловая система позволяет клиенту читать открытый для записи файл. При чтении открытого для записи файла длина последнего все еще записываемого блока неизвестна серверу метаданных. В этом случае клиент получает данные одной из копий для получения последнего значения размера перед началом чтения содержимого.

Архитектура системы ввода/вывода файловой системы HDFS особым образом оптимизирована для систем последовательной обработки, таких, как MapReduce, требующих высокой скорости передачи данных при последующих операциях чтения и записи. Продолжающиеся оптимизации должны улучшить время чтения/записи для приложений, требующих передачи данных в реальном времени или случайного доступа к данным.

8.3.2. Размещение блоков

При создании кластера большого размера использование плоской топологии для соединения серверов может оказаться непрактичным. Обычной практикой является установка серверов в множестве стоек. Серверы в стойке совместно используют свитч, а свитчи стоек соединены с помощью одного или нескольких центральных свитчей. Взаимодействие двух серверов из различных стоек происходит в результате преодоления данными множества свитчей. В большинстве случаев трафик между серверами из одной стойки превышает трафик между серверами из разных стоек. На Рисунке 8.3 изображен кластер, состоящий из двух стоек, каждая из которых содержит по три сервера.

Топология кластера
Рисунок 8.3. Топология кластера

Файловая система HDFS оценивает интенсивность трафика между двумя серверами на основании их удаления друг от друга. Удаление сервера от своего родительского сервера принимается за единицу отсчета. Расстояние между двумя серверами может быть рассчитано как сумма расстояний до их ближайших родительских серверов. Более короткое расстояние между двумя серверами подразумевает возможность повышения интенсивности трафика.

Файловая система позволяет администратору использовать сценарий, который будет возвращать информацию о принадлежности сервера к стойке на основе адреса этого сервера. Сервер метаданных является центральной точкой определения расположения стойки для каждого сервера данных приложений. Когда происходит регистрация сервера данных приложений сервером метаданных, данный сервер выполняет сценарий для определения принадлежности сервера данных приложений к конкретной стойке. Если такой сценарий не используется, сервер метаданных считает, что все серверы данных приложений расположены в одной стандартной стойке.

Размещение копий блоков является критичным параметром для надежности хранения данных и скорости чтения и записи файловой системы HDFS. Удачная политика размещения копий блоков должна улучшить надежность хранения данных, доступность данных и оптимизировать использование сети. На данный момент HDFS предоставляет интерфейс настройки политики размещения блоков, поэтому пользователи и исследователи могут экспериментировать и тестировать альтернативные политики, оптимальные для их приложений.

Стандартная политика размещения блоков HDFS является компромиссным решением, обеспечивающим баланс между минимизацией затрат ресурсов для записи данных и максимизацией доступности данных, надежности их хранения и общей скоростью чтения. При создании нового блока HDFS размещает первый блок на том сервере, на котором выполняется осуществляющий запись процесс. Вторая и третья копии располагаются на двух различных серверах в другой стойке. Остальные копии размещаются на случайных серверах с учетом условий, согласно которым на каждом сервере может располагаться не более одной копии блока и не более двух копий могут располагаться на серверах одной стойки, если это возможно. Выбор для размещения второй и третьей копии блока различных серверов обуславливает лучшее распространение копий блоков одного файла в пределах кластера. Если две первые копии блоков файла располагаются на серверах одной и той же стойки, то для любого файла две трети копий блока будут также располагаться на серверах одной стойки.

После того, как выбраны все целевые серверы, между ними организуется канал передачи данных с учетом их удаления от сервера с первой копией блока. Данные передаются всем серверам в заданной последовательности. Для чтения данных сервер метаданных в первую очередь проверяет, находится ли клиентский узел в рамках кластера. Если это условие выполняется, информация о расположении блоков, отсортированная по близости к узлу, на котором осуществляется чтение данных, передается клиенту. Чтение блока с серверов данных приложений осуществляется в этом же порядке.

Эта политика позволяет снизить интенсивность трафика между стойками и между узлами и в общем случае повысить скорость записи данных. Так как вероятность выхода из строя стойки значительно ниже вероятности выхода из строя сервера, эта политика не влияет на гарантии сохранности и доступности данных. В обычном случае использования трех копий данная политика позволяет снизить суммарную интенсивность трафика при чтении данных, так как блок располагается только на серверах из двух различных стоек вместо трех.

8.3.3. Управление репликацией

Сервер метаданных пытается убедиться в том, что каждый блок всегда имеет заданное количество копий. Он устанавливает факт недостатка копий или наличия излишних копий в момент доставки отчета о блоках от серверов данных приложений. В случаях, когда блок имеет лишние копии, сервер метаданных выбирает копию для удаления. Он предпочтет не сокращать количество стоек, на которых хранятся копии, а также предпочтет удалить копию с того сервера данных приложений, на котором наименьшее количество доступного дискового пространства. Целью данной политики является балансировка использования устройств хранения данных серверов данных приложений без снижения доступности блоков.

Когда количество копий блока становится недостаточным, он попадает в очередь приоритетной репликации. Блок только с одной копией имеет наивысший приоритет, а блок с несколькими копиями, составляющими более двух третьих необходимого объема репликации - низший. Программный поток, работающий в фоновом режиме, периодически сканирует начало очереди репликации для принятия решения о том, где размещать новые копии. Процесс репликации блоков использует простую политику размещения новых копий блоков. Если доступна одна копия блока, HDFS размещает следующую копию на сервере из другой стойки. В случае, когда блок имеет две доступных копии, если две существующие копии находятся на серверах одной стойки, третья копия создается на сервере из другой стойки; в противном случае третья копия размещается на другом сервере той же стойки, используемой существующей копией. В данном случае целью политики является снижение затрат ресурсов на создание новых копий.

Сервер метаданных также осуществляет контроль с целью недопущения размещения всех копий блока на серверах одной стойки. Если сервер метаданных устанавливает факт размещения всех копий блока на серверах одной стойки, он считает, что у блока недостаточно копий и создает копию блока на сервере из другой стойки, используя такую же политику размещения блоков, описанную выше. После того, как сервер метаданных получает уведомление о завершении создания копии, блок считается подвергнутым излишней репликации. Впоследствии сервер метаданных решает удалить старую копию, так как политика управления копиями предусматривает действия, не направленные на снижение количества серверов с копиями блоков в различных стойках.

8.3.4. Балансировщик

Стратегия размещения блоков файловой системы HDFS не учитывает использование дискового пространства серверами данных приложений. Она используется для запрета размещения новых, наиболее вероятно используемых, данных на небольшом множестве серверов данных приложений с большим количеством свободного дискового пространства. Следовательно, данные могут не всегда равномерно размещаться на серверах данных приложений. Дисбаланс также возникает при добавлении новых серверов в кластер.

Балансировщик является инструментом, позволяющим достигать равномерного использования дискового пространства серверами кластера HDFS. В качестве исходных данных используется пороговое значение, являющееся дробным числом из диапазона от 0 до 1. Кластер считается сбалансированным в том случае, когда степень использования диска каждого сервера данных приложений3 отличается от степени использования диска всеми серверами кластера4 не больше, чем на пороговое значение.

Данный инструмент функционирует в виде приложения, которое может быть запущено администратором кластера. Оно последовательно перемещает копии блоков с серверов данных приложений, на которых чрезмерно используется дисковое пространство, на сервера данных приложений, дисковое пространство которых не используется в достаточной мере. Единственным ключевым требованием к балансировщику является поддержание доступности данных. При выборе копии для перемещения и определении направления перемещения, балансировщик гарантирует, что данное решение не снизит количество копий блока и не уменьшит количество стоек, используемых серверами для хранения копий.

Балансировщик оптимизирует процесс перемещения данных, минимизируя копирование данных между серверами из различных стоек. Если балансировщик решает, что копия A должна быть перемещена на сервер в другой стойке и сервер из этой стойки уже содержит копию B этого же блока, будет использована копия B вместо копии A.

Параметр настройки ограничивает интенсивность трафика, генерируемого в ходе операций ребалансировки. Чем выше интенсивность трафика, тем быстрее кластер достигнет сбалансированного состояния, но это происходит в ущерб скорости работы приложений.

8.3.5. Сканер блоков

На каждом сервере данных приложений работает сканер блоков, который периодически сканирует хранящиеся на сервере копии блоков и проверяет соответствие данных блоков их сохраненным контрольным суммам. В течение каждого периода сканирования сканер блоков устанавливает допустимую интенсивность трафика для завершения процесса проверки блоков в течение заданного периода времени. Если клиент читает блок полностью и проверка его данных на соответствие контрольной сумме завершается успешно, он информирует об этом сервер данных приложений. После этого сервер данных приложений считает, что проверка блока прошла успешно.

Время проверки каждого блока сохраняется в журнале событий в понятном человеку формате. В любой момент времени в директории верхнего уровня сервера данных приложений хранится до двух файлов, являющихся используемым в данный момент и ранее журналами событий. Новые записи о времени проверки добавляются в используемый файл журнала событий. Соответственно, каждый сервер данных приложений хранит в памяти список копий блоков для сканирования, отсортированный по времени их последней проверки.

Всякий раз, когда читающий данные клиент или сканер блоков обнаруживает поврежденный блок, он оповещает об этом сервер метаданных. Сервер метаданных отмечает копию как поврежденную, но не планирует удаление этой копии незамедлительно. Вместо этого он начинает репликацию неповрежденной копии блока. Только тогда, когда количество неповрежденных копий блока достигает необходимого количества для данного блока, планируется удаление поврежденной копии. Целью этой политики является сохранение данных в течение такого долгого периода, как это возможно. Таким образом, даже если все копии блока будут повреждены, используемая политика позволит пользователю получить поврежденные данные копий.

8.3.6. Прекращение эксплуатации серверов данных приложений

Администратор кластера формирует список серверов, которые должны быть выведены из эксплуатации. Как только сервер помечен как выводимый из эксплуатации, он не будет использоваться для размещения новых копий блоков, но будет продолжать обслуживать запросы на чтение блоков. Сервер метаданных начнет планирование репликации блоков выводимого из эксплуатации сервера на другие серверы данных приложений. Как только сервер метаданных установит, что все блоки выводимого из эксплуатации сервера скопированы, сервер будет выведен из эксплуатации. После этого он может быть безопасно удален из кластера, что не приведет к риску снижения доступности данных.

8.3.7. Копирование данных между кластерами

При работе с большими наборами данных, перспективы копирования данных из кластера и в кластер HDFS приводят в уныние. Файловая система HDFS предоставляет инструмент под названием DistCp для копирования больших объемов данных внутри и вне кластера в параллельном режиме. Это задача системы MapReduce; каждый из процессов данной системы копирует часть исходных данных в целевую файловую систему. Фреймворк MapReduce автоматически производит планирование выполнения параллельных задач, обработку ошибок и восстановление работоспособности.

8.4. Практическое использование файловой системы в компании Yahoo!

Кластеры HDFS большого размера в компании Yahoo! включают в свой состав около 4000 серверов. Типичный сервер кластера имеет два четырехядерных процессора Xeon, работающих с тактовой частотой 2.5 ГГц, 4-12 подключенных напрямую жестких диска SATA (каждый объемом в два терабайта), 24 ГБ оперативной памяти и соединение Ethernet со скоростью 1 ГБит/с. Семьдесят процентов объема дискового пространства выделено для файловой системы HDFS. Остальное пространство зарезервировано для операционной системы (Red Hat Linux), файлов журнала, а также для записи выходных данных системы MapReduce (промежуточные данные системы MapReduce не хранятся в файловой системе HDFS).

Сорок серверов из одной стойки совместно используют IP-свитч. Свитчи стоек подключены к каждому из восьми центральных свитчей. Центральные свитчи позволяют установить соединение между стойками и ресурсами за границами кластера. В каждом кластере сервер метаданных и сервер резервных копий снабжаются объемом оперативной памяти до 64 ГБ; приложения никогда не выполняются на этих серверах. В общем, кластер из 4000 серверов располагает 11 ПБ (петабайт равен 1000 терабайтам) доступного дискового пространства для хранения трехкратно скопированных блоков, при этом 3.7 ПБ дискового пространства доступно для пользовательских приложений. В течение многих лет эксплуатации файловой системы HDFS, серверы из состава кластера улучшали свои характеристики благодаря использованию новых технологий. Новые серверы кластера всегда имели более производительные процессоры, большие объемы дискового пространства и оперативной памяти. Более медленные серверы с меньшими объемами дискового пространства выводились из эксплуатации или перемещались в кластеры, зарезервированные для использования в процессе разработки и тестирования Hadoop.

На примере кластера большого размера (из 4000 серверов) можно рассмотреть процесс обслуживания 65 миллионов файлов и 80 миллионов блоков. Так как каждый блок обычно подвергается трехкратной репликации, каждый сервер данных приложений хранит 60000 копий блоков. Каждый день пользовательские приложения создают по два миллиона новых файлов на каждом кластере. Кластер из 40000 серверов компании Yahoo! позволяет использовать сетевое хранилище объемом 40 ПБ.

Переход проекта в разряд ключевых компонентов набора технологий компании Yahoo! подразумевает появление ряда технических проблем, возникающих из-за отличия исследовательского проекта от проекта, отвечающего за сохранность множества петабайт корпоративных данных. Прежде всего эти проблемы связаны с устойчивостью работы системы и надежностью хранения данных. Но также важны и экономическая эффективность, возможность совместного использования ресурсов членами сообщества пользователей и простота обслуживания администраторами системы.

8.4.1. Долговечность хранения данных

Трехкратная репликация данных является надежной мерой, направленной против потери данных в результате непредсказуемых отказов серверов. Едва ли компания Yahoo! когда-нибудь пострадала от потери блока в таких обстоятельствах; для кластера большого размера значение вероятности потери блока в течение одного года меньше 0.005. Ключевым для понимания является тот факт, что около 0.8 процентов серверов выходят из строя в течение каждого месяца. (Даже если работоспособность сервера в конечном счете будет восстановлена, попыток восстановления хранившихся на нем данных обычно не предпринимается.) Таким образом, описанный в примере выше кластер больших размеров теряет каждый день один или два сервера. Этот же кластер повторно создаст копии 60000 блоков, хранившихся на отказавшем сервере, примерно за две минуты: повторная репликация проходит быстро, так как она выполняется в параллельном режиме и масштабируется в зависимости от размера кластера. Вероятность отказа нескольких серверов с копиями одного блока в течение двух минут, приводящего к утрате данных блока, достаточно низка.

Непредсказуемый отказ множества серверов является другой угрозой. В данном случае наиболее часто наблюдаемым является отказ свитча стойки или центрального свитча. Файловая система HDFS допускает потерю свитча стойки (каждый блок имеет копию на сервере из другой стойки). Некоторые неисправности центрального свитча могут действительно привести к отсоединению части стоек от кластера и в таком случае часть блоков может оказаться недоступной. В любом случае, восстановление работы свитча позволяет восстановить недоступные для кластера копии блоков. Другим непредсказуемым типом отказа является случайное или преднамеренное отключение электроснабжения кластера. Если отключение электроснабжения затрагивает стойки, скорее всего некоторые блоки станут недоступны. Но восстановление электроснабжения не избавит от проблем, так как половина процента серверов не переживет перезагрузку в результате отключения электроснабжения. По статистике, которая подтверждается на практике, кластер большого размера будет терять часть блоков в результате перезагрузки из-за отключения электроснабжения.

В дополнение к отказу серверов, хранимые данные также могут быть повреждены или потеряны. Сканер блоков сканирует все блоки кластера большого размера раз в две недели и находит около 20 поврежденных копий блоков в ходе сканирования. Поврежденные блоки заменяются в момент их обнаружения.

8.4.2. Возможности совместного использования ресурсов HDFS

По мере роста масштабов эксплуатации файловой системы HDFS, в нее были добавлены возможности для совместного использования ресурсов большим количеством отдельных пользователей. Первой такой возможностью был фреймворк прав доступа, спроектированный в соответствии со схемой прав доступа Unix для файлов и директорий. В этом фреймворке файлы и директории имели отдельные права доступа для владельца, других членов группы, ассоциированной с данным файлом или директорией, а также для других пользователей. Принципиальным отличием между правами доступа Unix (POSIX) и HDFS является отсутствие у обычных файлов в HDFS разрешений на исполнение и битов "sticky".

В ранних версиях HDFS применялся слабый механизм идентификации: ваше имя сообщалось серверу узлом, с помощью которого вы подключались. При доступе к HDFS клиентское приложение должно предоставить системе имен идентификационные данные, полученные из доверенного источника. Использование других служб управления идентификационными данными также возможно; начальная реализация использует Kerberos. Пользовательское приложение может использовать тот же фреймворк для подтверждения того, что система имен также является подлинной. Также система имен может запросить идентификационные данные у любого сервера данных приложений из состава кластера.

Общий объем доступного дискового пространства задается количеством серверов данных приложений и дисковым пространством каждого из этих серверов. Опыт эксплуатации ранних версий HDFS показал необходимость введения политики ограничения использования ресурсов группами пользователей. Данная политика должна быть введена не только для справедливого распределения ресурсов, но и для защиты от приложений, записывающих данные на множество узлов и исчерпывающих системные ресурсы, что тоже очень важно. В случае с HDFS метаданные всегда хранятся в оперативной памяти, поэтому размер пространства имен (зависящий от количества файлов и директорий) является также конечным ресурсом. Для управления ресурсами хранилища и пространства имен каждой директории должна соответствовать квота, устанавливающая общее дисковое пространство, которое может быть занято файлами в дереве поддиректорий пространства имен, начинающегося с этой директории. Отдельная квота также может быть установлена для общего количества файлов и директорий в этом дереве поддиректорий.

Архитектура файловой системы HDFS предусматривает тот факт, что большинство приложений будут передавать большие объемы данных, а программный фреймворк MapReduce чаще всего генерирует множество небольших результирующих файлов (по одному для каждой задачи), еще больше исчерпывая ресурсы пространства имен. Для удобства дерево директорий может быть упаковано в единственный файл архива Hadoop (Hadoop Archive file). Файл HAR аналогичен привычным файлам архивов tar, JAR или Zip, но операции файловой системы могут применяться к индивидуальным файлам этого архива, а также файл HAR может прозрачно использоваться в качестве исходного файла для выполнения задачи MapReduce.

8.4.3. Масштабирование и объединение файловой системы

Масштабирование сервера метаданных было ключевой задачей при разработке [Shv10]. Так как сервер метаданных хранит пространство имен и данные о расположении блоков в оперативной памяти, объем доступной памяти сервера метаданных ограничивает количество файлов, а также количество адресуемых блоков. Также данное обстоятельство ограничивает дисковое пространство, которое может обслуживаться сервером метаданных. Пользователям предлагается создавать файлы большего размера, но это предложение не выполняется, так как оно требует изменений в поведении приложений. Более того, мы встречаем новые классы приложений для HDFS, которым требуется хранить большое количество файлов малого размера. Для управления использованием диска были добавлены квоты, а также инструмент архивирования, но они не предоставляют фундаментального решения проблемы масштабирования.

Новая функция позволяет использовать несколько независимых пространств имен (и серверов метаданных) для разделения между ними физического дискового пространства кластера. Пространства имен используют блоки, сгруппированные в пул блоков (Block Pool). Пулы блоков аналогичны логическим единицам (LUN) системы хранилищ SAN, а пространство имен вместе с пулом блоков аналогично разделу файловой системы.

Этот подход имеет массу достоинств помимо расширения возможностей масштабирования: появляется возможность изоляции пространства имен различных приложений для улучшения доступности всего кластера. Абстракция в форме пула блоков позволяет другим службам использовать хранилище блоков, возможно, с другой структурой пространства имен. Мы планируем исследовать другие подходы для улучшения масштабирования, такие, как хранение только части данных пространства имен в оперативной памяти и использование действительно распределенной реализации сервера метаданных.

Приложения предпочитают использовать единственное пространство имен. Пространства имен могут монтироваться для работы в такой унифицированной форме. Таблица монтирования на стороне клиента является эффективным методом реализации этой идеи в сравнении с таблицей на стороне сервера: она позволяет избежать использования системы удаленных вызовов процедур (RPC) для взаимодействия с таблицей на стороне сервера, а также не является восприимчивой к отказам. Простейшим подходом является создание разделяемого пространства имен для всего кластера; этот подход может быть реализован путем добавления на стороне клиента аналогичной монтируемой таблицы для каждого клиента кластера. Таблицы монтирования на стороне клиента также позволяют приложениям создавать частные пространства имен. Эти пространства аналогичны пространствам имен процессов, используемым для работы с технологиями удаленного исполнения в распределенных системах [PPT+93, Rad94, RP93].

8.5. Выученные уроки

Небольшая команда смогла разработать файловую систему Hadoop и сделать ее достаточно стабильной и надежной для промышленной эксплуатации. Успех в большей степени был достигнут благодаря ее чрезвычайно простой архитектуре: копируемым блокам, периодическим отчетам о состоянии блоков и центральному серверу метаданных. Отход от точного следования семантикам POSIX также помог. Хотя решение о хранении всех метаданных в оперативной памяти и ограничило возможности масштабирования пространства имен, оно позволило достаточно просто реализовать сервер метаданных: удалось избежать использования сложных механизмов блокировок, применяемых в стандартных файловых системах. Другой причиной успеха файловой системы Hadoop было ее незамедлительное промышленное использование в компании Yahoo!, так как это обстоятельство позволило быстро и последовательно вносить улучшения. Файловая система очень надежна и нарушения работы сервера метаданных происходят очень редко; на самом деле большая часть периодов неработоспособности связана с обновлениями программного обеспечения. Только недавно в файловой системе были применены (описанные в руководстве) решения, позволяющие повысить отказоустойчивость.

Многие были удивлены выбором языка Java для создания масштабируемой файловой системы. Хотя из-за использования Java и пришлось столкнуться с проблемами масштабирования сервера метаданных ввиду дополнительных затрат памяти на объекты и механизма сборки мусора, Java обуславливает надежность системы; использование этого языка позволяет избежать ошибок, связанных с указателями и управлением памятью.

8.6. Благодарности

Мы хотим поблагодарить компанию Yahoo! за инвестиции в разработку Hadoop и распространение открытого исходного кода; 80% кода HDFS и MapReduce было разработано в компании Yahoo! Мы благодарим всех разработчиков и сотрудников, имеющих отношение к Hadoop, за их весомый вклад в разработку.

Сноски

  1. http://hadoop.apache.org
  2. http://www.lustre.org
  3. Задается как отношение использованного дискового пространства сервера ко всему доступному дисковому пространству сервера.
  4. Задается как отношение использованного дискового пространства кластера ко всему доступному дисковому пространству кластера.

На главную -> MyLDP -> Тематический каталог ->

Непрерывная интеграция

Глава 9 из книги "Архитектура приложений с открытым исходным кодом", том 1.

Оригинал: Continuous Integration
Автор: C. Titus Brown, Rosangela Canino-Koning
Дата публикации: 7 Июля 2012 г.
Перевод: А.Панин
Дата перевода: 23 Апреля 2013 г.

Системы непрерывной интеграции (Continuous Integration (CI) systems) предназначены для автоматической и регулярной сборки и тестирования программных продуктов. Хотя их основным преимуществом и является возможность устранения длительных периодов времени между сборкой и тестовыми запусками, данные системы также упрощают и автоматизируют выполнение других утомительных задач. Эти задачи включают в себя кроссплатформенное тестирование, регулярное выполнение медленных операций по перемещению больших объемов данных или сложно настраиваемых тестов, проверку достижения необходимой производительности при использовании устаревших платформ, выявление тестов, которые периодически заканчиваются неудачами и регулярное создание пакетов для актуальных версий программных продуктов. Так как автоматизация процессов сборки и тестирования необходима для реализации процесса непрерывной интеграции, данный процесс обычно оказывается первым шагом к реализации фреймворка непрерывного развертывания, в котором обновления программного обеспечения могут производиться в процессе работы систем незамедлительно после тестирования.

Непрерывная интеграция является актуальной темой не только из-за ее значительной роли в гибкой методологии разработки (Agile software methodology). В течение прошедших лет было представлено множество инструментов непрерывной интеграции с открытым исходным кодом, разработанных с использованием и предназначенных для работы с различными языками программирования, реализующих большой диапазон возможностей в контексте набора различных архитектурных моделей. Целью данной главы является описание стандартных наборов возможностей, реализуемых системами непрерывной интеграции, обсуждение доступных архитектурных моделей и установление того, какие возможности можно или нельзя реализовать без лишних сложностей в рамках выбранной архитектуры.

Ниже мы кратко опишем ряд систем, иллюстрирующих различные доступные при проектировании систем непрерывной интеграции архитектурные решения. Первая система, Buildbot использует модель ведущих/ведомых серверов; вторая, Cdash использует модель сервера обработки отчетов; третья, Jenkins, использует гибридную модель; и четвертая, Pony-Build, использует децентрализованный сервер обработки отчетов, разработанный с использованием языка Python, который мы будем использовать в качестве примера в обсуждении.

9.1. Многообразие систем непрерывной интеграции

В среде архитектур систем непрерывной интеграции прослеживается доминирование двух противоположных моделей: архитектур ведущих/ведомых серверов, в которых центральный сервер осуществляет управление и контролирует работу удаленных серверов для сборки; а также архитектур серверов обработки отчетов, в которых центральный сервер обрабатывает отчеты о сборке, отправленные клиентами. Все системы непрерывной интеграции, о существовании которых нам известно, выбрали некоторую комбинацию возможностей, присущих этим двум архитектурам.

Наш пример централизованной архитектуры, система Buildbot, состоит из двух частей: центрального сервера, также называемого buildmaster и предназначенного для планирования и координации процесса сборки программного обеспечения одним или большим количеством подключенных клиентов; а также клиентов, называемых buildslaves и непосредственно выполняющих сборку. Центральный сервер (buildmaster) является центральной точкой для подключения и хранит информацию о том, какие клиенты должны выполнять команды и в какой последовательности. Клиенты (buildslaves) соединяются с центральным сервером и получают подробные инструкции. Процесс настройки клиента заключается в установке программного обеспечения, идентификации центрального сервера, а также вводе данных для соединения с центральным сервером. Процессы сборки программного обеспечения планируются центральным сервером, а их вывод передается от клиентов центральному серверу и хранится на нем с целью последующего просмотра с помощью веб-интерфейса, а также отправки с помощью других систем отчетов и уведомлений.

На другом крае спектра архитектур находится система CDash, используемая системами виртуализации Visualization Toolkit (VTK)/Insight Toolkit (ITK) от компании Kitware, Inc. На самом деле, система CDash представлена сервером обработки отчетов, спроектированным для хранения и вывода информации, полученной от клиентских компьютеров, выполняющих операции с использованием систем CMake и CTest. При использовании системы CDash клиенты инициируют ряд процессов сборки и тестирования, записывают результаты сборки и тестирования, после чего соединяются с сервером CDash с целью сохранения с помощью него отчета.

Наконец, третья система, Jenkins (известная как Hudson до момента смены названия в 2011 году) может функционировать в двух режимах. В случае использования системы Jenkins, сборка с последующей отправкой отчетов центральному серверу может быть либо инициирована независимо; либо серверы могут исполнять команды центрального сервера Jenkins, который впоследствии будет планировать и управлять ходом процессов сборок.

Централизованная и децентрализованная модели имеют некоторые сходные возможности, и, как мы видим на примере системы Jenkins, эти модели могут сосуществовать в рамках одной реализации. Однако, системы Buildbot и CDash значительно отличаются друг от друга: помимо сходных функций сборки программного обеспечения и создания отчетов о сборке, все остальные аспекты их архитектуры кардинально отличаются. Почему?

Кроме того, в какой степени выбор архитектуры обуславливает простоту или сложность реализации определенных возможностей? Являются ли некоторые возможности следствием использования централизованной модели? И насколько расширяемы возможности существующих реализаций - могут ли они быть без сложностей модифицированы с целью поддержки новых механизмов работы с отчетами, подвергаться масштабированию для работы с множеством пакетов или выполнять операции сборки и тестирования программных продуктов в облачном окружении?

9.1.1. Какие функции выполняются программным обеспечением непрерывной интеграции

Основные функции системы непрерывной интеграции достаточно просты: сборка программного обеспечения, выполнение тестирования и отправка отчета о результатах. Сборка, тестирование и создание отчетов могут осуществляться с помощью сценария, выполнение которого запланировано в форме задачи cron: данный сценарий должен проверить наличие новой копии исходного кода в системе управления версиями (VCS), выполнить сборку исходного кода, после чего выполнить тестирование. Вывод должен быть записан в файл и либо храниться в установленном месте, либо отправляться с помощью электронной почты в случае неполадок. Этот процесс достаточно просто реализуем: в UNIX, например, весь этот процесс для большинства пакетов Python может быть реализован с помощью семистрочного сценария:

cd /tmp && \
svn checkout http://some.project.url && \
cd project_directory && \
python setup.py build && \
python setup.py test || \
echo build failed | sendmail notification@project.domain
cd /tmp && rm -fr project_directory

На Рисунке 9.1 незаштрихованные прямоугольники представляют отдельные подсистемы и функции в рамках системы. Стрелки указывают направления информационных потоков между различными компонентами. Облако представляет потенциальную возможность удаленного выполнения процессов сборки. Заштрихованные прямоугольники представляют потенциальные объединения подсистем; например, мониторинг процесса сборки включает в себя мониторинг самого процесса сборки, а также мониторинг состояния системы (загрузки центрального процессора, нагрузки вследствие операций ввода/вывода, использования оперативной памяти, и.т.д.)

Внутреннее устройство системы непрерывной интеграции
Рисунок 9.1: Внутреннее устройство системы непрерывной интеграции

Но эта простота обманчива. Реальные системы непрерывной интеграции обычно выполняют намного больше действий. В дополнение к инициированию и приему отчетов о результатах выполнения удаленных процессов сборки, программное обеспечение непрерывной интеграции должно поддерживать любые из следующих дополнительных возможностей:

Высокоуровневое представление этих возможных компонентов системы непрерывной интеграции показано на Рисунке 9.1. Программное обеспечение непрерывной интеграции обычно реализует некоторое подмножество этих компонентов.

9.1.2. Внешние взаимодействия

Системам непрерывной интеграции также требуется осуществлять взаимодействия с другими системами. Существует несколько типов потенциальных взаимодействий:

9.2. Архитектуры

В ходе разработки проектов Buildbot и CDash были выбраны диаметрально противоположные архитектуры, при этом были реализованы перекрывающиеся, но разделенные наборы возможностей. Ниже мы опишем эти наборы возможностей и обсудим то, насколько эти возможности проще или сложнее реализовать в зависимости от выбранной архитектуры.

9.2.1. Модель реализации: Buildbot

Архитектура системы Buildbot
Рисунок 9.2. Архитектура системы Buildbot

Система Buildbot использует архитектуру ведущих/ведомых серверов, которая предусматривает единственный центральный сервер и множество управляемых им серверов для сборки. Удаленное исполнение команд осуществляется в полном соответствии со сценарием центрального сервера в реальном времени: центральный сервер отправляет команды для исполнения каждой из удаленных систем, которые начинают выполняться сразу же после завершения выполнения предыдущих команд. Планирование выполнения команд и запросы сборки не только координируются, но и полностью контролируются центральным сервером. Встроенной абстракции для рецептов сборки не существует, за исключением простейшей интеграции с системой контроля версий ("наш код в этом репозитории") и разделением команд для работы с директорией для сборки и команд для работы в директории для сборки. Специфические для операционных систем команды обычно задаются на этапе настройки.

Система Buildbot поддерживает постоянное соединение с каждой системой для сборки и осуществляет управление и координацию исполнения задач этими системами. Управление удаленными машинами посредством постоянного соединения добавляло сложностей при практической реализации и в течение длительного периода времени являлось источником ошибок. Длительная поддержка надежно функционирующих сетевых соединений не так проста и тестирование приложений, взаимодействующих с локальным графическим интерфейсом посредством сети, требует больших затрат труда. Особенно сложно работать с предупреждениям от операционной системы, выводимыми в окнах сообщений. Однако, это постоянное соединение упрощает процессы координации и управления ресурсами, так как для выполнения задач ведомые машины находятся в полном распоряжении ведущей машины.

Тип непосредственного управления, применяемый в модели реализации Buildbot, делает централизованную координацию ресурсов для сборки достаточно простой. В системе Buildbot предусмотрены блокировки для центрального и ведомых серверов на центральном сервере, поэтому могут координироваться как глобальные сборки в рамках всей системы, так и локальные сборки в рамках ресурсов отдельных машин. Это обстоятельство делает систему Buildbot в большей степени пригодной для больших систем, на которых проводится тесты системной интеграции, т.е. тесты, в ходе которых осуществляется взаимодействие с базами данных или другими связанными с затратами ресурсов программными компонентами.

Следует учесть тот факт, что централизованная конфигурация обуславливает проблемы в случае использования распределенной модели. Каждый новый сервер для сборки должен быть явно задан при настройке центрального сервера, что делает невозможным динамическое подключение к центральному серверу новых серверов для сборки и их последующую эксплуатацию. Более того, так как каждый сервер для сборки находится под полным управлением центрального сервера, серверы сборки уязвимы для умышленных и случайных операций задания некорректных настроек: центральный сервер буквально осуществляет тотальный контроль клиента, контролируя также ограничения безопасности операционной системы клиента.

Одним из ограничений возможностей системы Buildbot является невозможность реализации простого способа возврата собранных программных продуктов на центральный сервер. Например, статистика охвата кода и бинарные пакеты хранятся на удаленном сервере для сборки, при этом не существует API для передачи их на центральный сервер с целью хранения и последующего распространения. Причины отсутствия данной возможности непонятны. Возможно, это последствие использования ограниченного набора распространяемых в составе Buildbot командных абстракций, которые главным образом направлены на удаленное выполнение команд серверами для сборки. Также, возможно это вызвано решением использовать соединение между центральным сервером и серверами сборки как систему контроля, а не механизм удаленного вызова процедур.

Другим последствием использования модели ведущих/ведомых серверов и ее ограниченного канала для взаимодействия является невозможность отправки отчетов о загрузке системы центральному серверу от ведомых серверов, поэтому центральный сервер не может избегать больших нагрузок на системы сборки.

Внешние уведомления о использовании центрального процессора в результате сборки в полной мере обрабатываются центральным сервером и новые службы уведомлений должны быть реализованы именно на стороне центрального сервера. Аналогично, новые запросы сборки должны отправляться напрямую центральному серверу.

9.2.2. Модель реализации: CDash

Архитектура CDash
Рисунок 9.3: Архитектура CDash

В отличие от системы Buildbot, система CDash реализует модель сервера обработки отчетов. В этой модели сервер CDash выступает в качестве центрального репозитория для хранения информации о осуществленных удаленно сборках вместе с сопутствующими отчетами о неполадках при сборке и тестировании, результатах анализа охвата кода и использования памяти. Сборки осуществляются на удаленных клиентах по их собственному графику, после чего отправляются отчеты о сборке в формате XML. Отчеты о сборке могут отправляться как "официальными" клиентами, так и сторонними разработчиками или пользователями, выполняющими процесс сборки на своих машинах.

Реализация этой простой модели стала возможной благодаря тесной концептуальной интеграции между системой CDash и другими элементами инфраструктуры сборки программных компонентов компании Kitware: Cmake, системы настройки и сборки, CTest, системы тестирования и CPack, системы создания пакетов. Это программное обеспечение создает механизм, с помощью которого рецепты сборки, тестирования и создания пакетов могут быть реализованы на достаточно высоком уровне абстракции независимо от используемой операционной системы.

Процесс работы системы CDash с инициированием действий на стороне клиентов упрощает множество аспектов реализации процесса непрерывной интеграции на стороне клиента. Решение о начале сборки принимается клиентами, поэтому условия на стороне клиентов (время дня, высокая нагрузка, и.т.д.) могут быть приняты во внимание перед началом сборки. Клиенты могут появляться и исчезать по собственному желанию, что сводит на нет сложности проведения сторонних сборок и "сборок в облаке". Собранные программные продукты могут быть отправлены на центральный сервер с помощью простого механизма загрузки.

Однако, результатом использования модели обработки отчетов в CDash стало отсутствие многих полезных функций из системы Buildbot. Отсутствует централизованная координация ресурсов, причем эта функция не может быть просто реализована в распределенном окружении с непроверенными или ненадежными клиентами. Механизм отчетов о выполнении операций также не реализован: для реализации сервер должен позволять осуществлять последовательное обновление состояния сборки. И, конечно же, не существует способа для глобального запроса сборки с гарантией его выполнения анонимными клиентами в ответ на запрос - клиенты должны рассматриваться как ненадежные.

Не так давно в систему CDash были добавлены функции для работы системы сборки в облаке "@Home", в которой клиенты предоставляют ресурсы для сборки серверу CDash. Клиенты опрашивают сервер на наличие запросов на сборку, выполняют сборку в соответствии с запросами и возвращают результаты серверу. В текущей реализации (от октября 2010 года) сборки должны быть инициированы вручную на стороне сервера и клиенты должны быть соединены с сервером для предоставления своих услуг по сборке. Однако, эту модель достаточно просто усовершенствовать до более обобщенной модели планируемых сборок, в которой сборки запрашиваются автоматически сервером в случае доступности подходящего клиента. Система "@Home" по концепции очень похожа на систему Pony-Build, которая будет описана позднее.

9.2.3. Модель реализации: Jenkins

Jenkins является широко применяемой системой непрерывной интеграции, реализованной с использованием языка программирования Java; до начала 2011 года этот программный продукт был известен под названием Hudson. Данная система может выступать в роли как отдельной системы непрерывной интеграции, работая в локальной системе, так и координатора удаленных сборок, или даже пассивного сервера, принимающего информацию о сборках от удаленных серверов. Система использует преимущества стандарта JUnit XML для модульного тестирования и создания отчетов о степени охвата кода и интеграции отчетов от различных инструментов тестирования. Разработка Jenkins была начата компанией Sun, но система широко применяется в различных компаниях и имеет надежное сообщество, сформированное вокруг ее открытого исходного кода.

Система Jenkins работает в гибридном режиме и по умолчанию использует режим выполнения сборок центральным сервером, при этом позволяя использовать множество методов для выполнения удаленной сборки, включающих в себя методы, инициирующие сборку как на стороне сервера, так и на стороне клиента. Однако, как и система Buildbot, данная система в первую очередь проектировалась для управления с помощью центрального сервера, но была адаптирована для поддержки широкого круга механизмов выполнения распределенных задач, включающих в себя механизмы управления виртуальными машинами.

Система Jenkins может управлять множеством удаленных машин с помощью соединения SSH, инициированного центральным сервером или клиентом с использованием технологии JNLP (Java Web Start). Это соединение является двухсторонним и позволяет осуществлять взаимодействие объектов и последовательную передачу данных.

В системе Jenkins применена сложная архитектура расширений, создающая абстракцию над функциями этого соединения, что позволило разработать множество сторонних расширений для поддержки возврата бинарных сборок и более важных выходных данных.

Для контролируемых центральным сервером задач система Jenkins предусматривает расширение "блокировок", позволяющее избежать параллельного выполнения этих задач, при этом по состоянию на январь 2011 года разработка этого расширения еще не завершена.

9.2.4. Модель реализации: Pony-Build

Архитектура системы Pony-Build
Рисунок 9.4: Архитектура системы Pony-Build

Pony-Build является экспериментальной децентрализованной системой непрерывной интеграции, разработанной с использованием языка Pyhon. Она состоит из трех основных компонентов, изображенных на Рисунке 9.4. Сервер обработки результатов выступает в роли централизованной базы данных, хранящей результаты, полученные от отдельных клиентов. Клиенты независимо хранят всю конфигурационную информацию и окружение сборки, совмещенное с легковесной клиентской библиотекой для облегчения доступа к репозиториям системы контроля версий, управления процессом сборки и передачи результатов серверу. Сервер обработки отчетов не является обязательным и поддерживает работу простого веб-интерфейса, предназначенного для вывода отчетов о результатах сборки и осуществления запросов новых сборок. В нашей реализации серверы обработки отчетов и результатов реализованы в рамках одного многопоточного процесса, но не имеют объединения на уровне API и без лишних сложностей могут быть запущены независимо друг от друга.

Эта простая модель усовершенствована с помощью множества различных точек вызова функций для работы с сетью и механизмов удаленного вызова процедур, способствующих отправке уведомлений о сборке и изменениях в системе, а также позволяющих воздействовать на процесс сборки. Например, вместо привязки уведомлений от репозитория исходного кода системы контроля версий напрямую к системе сборки, удаленные запросы направляются к системе обработки отчетов, которая передает их серверу обработки результатов. Аналогично, вместо формирования уведомлений о изменениях в новых сборках и отправки их с помощью электронной почты, системы мгновенных сообщений или других сервисов напрямую серверу обработки отчетов, отправка уведомлений осуществляется с использованием активного протокола уведомлений PubSubHubbub (PuSH). Эта особенность позволяет получать широкому кругу различных приложений "интересующие" их уведомления (на данный момент набор уведомлений ограничен уведомлениями о новых сборках и неудачных сборках) с использованием точки вызова функции PuSH (PuSH webhook).

Преимущества этой разделенной модели весьма значительны:

К сожалению, существует также множество серьезных недостатков, как и в случае модели системы CDash:

Двумя другими аспектами реализации систем непрерывной интеграции, рассмотренными в ходе работы над Pony-Build являются предпочтительные методы реализации механизма рецептов сборки и управления доверенными ресурсами. Эти аспекты взаимосвязаны, так как при использовании рецептов сборки клиентами выполняется произвольный код.

9.2.5. Рецепты сборки

Рецепты сборки добавляют полезный уровень абстракции, особенно в случаях сборки кроссплатформенного программного обеспечения или использования мультиплатформенной системы сборки. Например, система CDash использует строгий тип рецептов сборки; большая часть, а возможно и все программное обеспечение, использующее для сборки систему CDash, подвергается обработке с помощью таких инструментов, как CMake, CTest и CPack, которые разрабатывались с учетом возможностей мультиплатформенных сборок. Это идеальная ситуация с точки зрения системы непрерывной интеграции, так как она может просто делегировать решение всех проблем сборочному инструментарию.

Однако, это справедливо не для всех языков программирования и окружений сборки. В экосистеме Python проводится стандартизация систем distutils и distutils2, предназначенных для сборки программных пакетов, но на данный момент не выработано стандарта, описывающего процесс подбора и выполнения тестов, а также объединения их результатов. Более того, во многие более сложные пакеты программ на языке Python добавлены специализированные логические функции для сборки, так как механизм расширений distutils позволяет выполнять произвольный код. Эта ситуация типична для всех инструментов сборки: наряду с существованием удачно стандартизированного набора команд для исполнения, всегда найдутся исключения и дополнения.

Реализация рецептов для сборки, тестирования и упаковки приводит к необходимости решения двух проблем: во-первых, рецепты должны создаваться независимо от платформы таким образом, чтобы один и тот же рецепт мог использоваться для сборки программного обеспечения на множестве систем; и во-вторых, они должны позволять осуществлять внесение изменений в процесс сборки с учетом особенностей программного обеспечения.

9.2.6. Управление доверенными ресурсами

Это третья проблема. Широкое использование рецептов сборки в системе непрерывной интеграции приводит к необходимости управления еще одним доверенным ресурсом: проверяться должно не только само программное обеспечение (так как клиенты системы выполняют произвольный код), но и рецепты сборки (так как они тоже должны иметь возможность выполнять произвольный код).

Эти вопросы управления доверенными ресурсами достаточно просто решаются в жестко контролируемом окружении, т.е. в компании, где клиенты сборки и система непрерывной интеграции являются частью внутреннего рабочего процесса. В других окружениях разработки, однако, заинтересованные третьи стороны могут предоставлять услуги сборки, например, проектам с открытым исходным кодом. Идеальным решением была бы поддержка включения стандартных рецептов для сборки в комплект поставки программного обеспечения на уровне сообществ, ведь сообщество Python уже задало это направление развития, создав систему distutils2. Альтернативным решением может быть разрешение использования рецептов сборки с цифровыми подписями, для того, чтобы надежные участники сообщества могли распространять подписанные рецепты сборки и клиенты системы непрерывной интеграции устанавливали, следует ли доверять рецептам сборки.

9.2.7. Выбор модели

По нашему опыту, свободное объединение механизма удаленного вызова процедур и точек вызова функций в рамках модели системы непрерывной интеграции чрезвычайно просто реализуемо с учетом игнорирования всех требований тесной координации, которая обуславливает сложное соединение серверов. Простое выполнение удаленных проверок и сборок предполагает одинаковые требования к архитектуре в случаях как локальной, так и удаленной сборки; накопление информации о сборке (была ли сборка удачна/неудачна и.т.д.) в большей степени обуславливается требованиями к программному обеспечению на стороне клиента; а действия по отслеживанию информации на основе архитектуры и результатов обуславливаются теми же базовыми требованиями. Таким образом, простейшая система непрерывной интеграции может быть достаточно просто реализована с использованием модели обработки отчетов.

Мы также считаем модель свободного объединения компонентов очень гибкой и расширяемой. Добавление функций создания отчетов о новых типах результатов, механизмов уведомлений и рецептов сборки выполняется просто, так как компоненты четко разделены и достаточно независимы. Для разделенных компонентов четко установлены выполняемые ими задачи, также их просто тестировать и модифицировать.

Единственным сложным аспектом при реализации системы удаленных сборок в рамках аналогичной CDash модели свободного объединения компонентов является координация сборок: запуск и остановка процессов сборок, отчеты о выполняющихся сборках и координация блокировок ресурсов между несколькими клиентами технически более сложны в сравнении с остальной реализацией.

Достаточно просто сделать вывод о том, что модель свободного объединения компонентов "лучше" всех других, но очевидно, что это утверждение корректно только в том случае, когда координация сборок не требуется. Решение о выборе модели должно приниматься на основе требований проектов, для сборки которых будет использоваться система непрерывной интеграции.

9.3. Будущее систем непрерывной интеграции

При размышлении над устройством системы Pony-Build, мы сформулировали описание нескольких возможностей, которые хотелось бы увидеть в будущих выпусках систем непрерывной интеграции.

9.3.1. Заключительные размышления

Описанные выше системы непрерывной интеграции реализуют возможности, соответствующие их архитектуре, в то время, как система с гибридной архитектурой Jenkins начала свое развитие с реализации архитектуры ведущего/ведомых серверов, в которую позднее были добавлены возможности, присущие архитектуре обработки отчетов со свободным объединением компонентов.

На основе этого наблюдения можно сделать вывод о том, что выбор архитектуры обуславливает функции системы. Конечно же, это не так. Скорее выбор архитектуры задает направление развития и реализации определенного набора функций. В ходе работы над системой Pony-Build мы были удивлены тем, настолько наш начальный выбор аналогичной системе CDash архитектуры обработки отчетов повлиял на будущие решения в области проектирования и реализации функций. Некоторые практические решения, такие, как уход от централизованной настройки и реализация подсистемы планирования в системе Pony-Build были приняты при учете наших условий эксплуатации данной системы: нам было необходимо обеспечить возможность динамического подключения удаленных клиентов для сборки, что сложно реализовать в случае использования системы Buildbot. Другие не реализованные нами в системе Pony-Build возможности, такие, как отчеты о состоянии сборки и централизованные блокировки ресурсов, были желательны, но очень сложны в реализации, для которой отсутствовали весомые аргументы.

Похожая логика может быть применена и к таким системам, как Buildbot, CDash и Jenkins. В каждом случае отсутствуют полезные возможности, вероятно, из-за несовместимости с выбранной архитектурой. Однако, после обсуждения с участниками сообществ Buildbot и CDash, а также чтения веб-сайта проекта Jenkins у нас сформировалось мнение о том, что необходимые функции были выбраны с самого начала, после чего началась разработка системы с использованием архитектуры, которая упрощает реализацию этих функций. Например, система CDash обслуживает сообщество с небольшим числом основных разработчиков, которые ведут разработку программного обеспечения, используя централизованную модель. Их главной задачей является поддержание работоспособности программного обеспечения на основных машинах, а вторичной - прием сообщений об ошибках от разбирающихся в технических тонкостях пользователей. В то же время, система Buildbot широко применяется в сложных окружениях с множеством клиентов, действия которых должны координироваться для доступа к разделяемым ресурсам. Более гибкий формат файлов настройки системы Buildbot с множеством параметров для планирования, изменения режима работы механизмов уведомлений и блокировок ресурсов подходит для этой цели лучше других. Наконец, система Jenkins нацелена на простоту использования и упрощение процесса непрерывной интеграции, используя для настройки графический интерфейс и параметры настройки для работы на локальном сервере.

Социальная составляющая процесса разработки программного обеспечения с открытым исходным кодом является другим фактором изменения соотношения между выбранной архитектурой и функциями: можете ли вы предположить, что разработчики выберут проекты с исходным кодом, основываясь на соответствии архитектуры проекта и его функций их условиям эксплуатации? Если это так, то их вклад в разработку будет направлен в общем случае на улучшение работы в условиях эксплуатации, которым соответствует проект. Таким образом, проекты могут быть ограничены определенным набором функций, так как участники процесса разработки сами проявляют инициативу и могут избегать архитектур, которые не предоставляют нужных им возможностей. Это утверждение было, конечно же, справедливо в нашем случае, когда мы решили реализовать новую систему Pony-Build вместо внесения изменений в систему Buildbot: архитектура системы Buildbot просто не подходила для сборки сотен тысяч пакетов.

Существующие системы непрерывной интеграции в основном создаются с использованием двух различных архитектур и обычно реализуют только часть из полезных функций. С развитием систем непрерывной интеграции и ростом числа их пользователей, мы ожидаем реализации дополнительных функций: однако, реализация этих функций может быть обусловлена начальным выбором архитектуры. Будет интересно проследить процесс развития систем этого типа.

9.3.2. Благодарности

Мы благодарим Greg Wilson, Brett Cannon, Eric Holscher, Jesse Noller и Victoria Laidler за интересные обсуждения систем непрерывной интеграции в общем и системы Pony-Build в частности. Некоторые студенты участвовали в развитии системы Pony-Build, а именно Jack Carlson, Fatima Cherkaoui, Max Laite и Khushboo Shakya.

10.1. Разработка Jitsi

Тремя наиболее важными ограничениями, которые мы должны были иметь в виду при разработке Jitsi (в то время имевшего название SIP Communicator), были поддержка многих протоколов, кросс-платформенные операции и удобство для разработчика.

С точки зрения разработчик поддержка многих протоколов сводится к наличию общего интерфейса для всех протоколов. Другими словами, когда пользователь отправляет сообщение, наш графический интерфейс пользователя должно всегда вызывать один тот же метод sendMessage независимо от того, будет ли текущий выбранный протокол фактически вызывать метод, который называется sendXmppMessage или sendSipMsg.

Тот факт, что большая часть нашего кода написана на языке Java, удовлетворяет, в значительной мере, нашему второму ограничению: кросс-платформенности операций. Тем не менее, есть вещи, которые в среде Java Runtime Environment (JRE) не поддерживаются или делаются не так, как нам это нужно, например, захват видео с веб-камеры. Поэтому нам требуется использовать DirectShow в Windows, QTKit в Mac OS X и Video for Linux [2] в Linux. Точно также, как и с протоколами, те части кода, которые управляют видео, не должны касаться деталей управления (они достаточно сложны для этого).

Наконец, удобство для разработчика означает, что разработчикам должно быть легко добавлять новые функции. Сегодня VoIP пользуются миллионы, причем тысячами различных способов; различные провайдеры и разработчики сервис-услуг придумывают различные варианты использования и предлагаю новые идеи, касающиеся новых возможностей. Мы должны быть уверены, что им будет легко использовать Jitsi так, как они хотят. Тот, кому нужно добавить что-то новое, должен прочитать и понять только те части проекта, которые требуется модифицировать или расширить. Кроме того, изменение, делаемое одним человеком, должно минимальным образом влиять на все остальные работающие части проекта.

Если подвести итог, то нам нужна была среда, в которой различные части кода были бы относительно независимы друг от друга. Должна была быть возможность в зависимости от операционной системы легко менять некоторые части, запускать другие, например, протоколы, параллельно тем, что уже действуют, и иметь возможность полностью переписать любую из частей, причем так, чтобы остальной код работал без каких-либо изменений. Наконец, мы хотели иметь возможность легко включать и выключать отдельные части, а также иметь возможность загружать в наш список плагины через Интернет.

Мы кратко записали требования к нашему собственному фреймворку, но вскоре отказались от этой идеи. Нам не терпелось как можно скорее начать писать код для VoIP и IM и нам казалось, что не интересно тратить пару месяцев на фреймворк для плагинов. Кто-то предложил технологию OSGi, и она, как оказалось, подходит идеально.

10.2. Jitsi и фреймворк OSGi

Об OSGi написаны целые книги, так что мы не собираемся рассказывать о том, для чего предназначен этот фреймворк. Вместо этого мы только объясним, что он дает нам, и то, как мы используем его в Jitsi.

Кроме всего прочего, OSGi является модульным. Возможности в приложениях OSGi разделены на отдельные сборки. Сборка OSGi представляет собой немного большим, чем обычные файлы JAR, которые используются для распространения Java-библиотек и приложений. Jitsi представляет собой коллекцию таких сборок. Имеется сборка, которая отвечает за подключение к Windows Live Messenger, другая, в которой реализован XMPP, еще сборка, которая обрабатывает GUI, и так далее. Все эти сборки работают вместе в среде, которая с нашем случае предоставляется при помощи Apache Felix — реализации OSGi с открытым исходным кодом.

Все эти модули должны работать вместе. Сборка графического пользовательского интерфейса должна посылать сообщения через сборки, реализующие протоколы, которые, в свою очередь, должны запоминать их с помощью сборок обработки истории сообщений. Это то, для чего предназначены сервисы OSGi: они представляют собой часть сборки, которая видна всем остальным. Сервис OSGi чаще всего является группой интерфейсов Java, которые позволяют использовать специальные функциональные возможности, например, ведение журнала, отсылки сообщений по сети или получение списка последних вызовов. Классы, в которых фактически реализованы функциональные возможности, известны как реализация сервисов. Большинство из них носят название интерфейса сервиса, который они реализуют, с суффиксом "Impl" в конце названия (например, ConfigurationServiceImpl). Фреймворк OSGi позволяет разработчикам скрывать реализации сервисов и быть уверенным, что они никогда не будут видны снаружи сборки, в которой они находятся. Таким образом, другие сборки могут использовать их только через интерфейсы сервисов.

В большинстве сборок также есть активаторы. Активаторы являются простыми интерфейсами, в которых определены мотоды start и stop. Каждый раз, когда Felix в Jitsi загружает или удаляет сборку, он вызывает эти методы с тем, чтобы сборку можно было подготовить к работе или к завершению. Когда эти методы вызываются, Felix передает им параметр, называемый BundleContext. BundleContext предоставляет сборкам метод подключения к среде OSGi. Таким образом, они могут обнаружить, какой сервис OSGi они должны использовать, или зарегистрироваться сами (рис.10.1).

Рис.10.1: Активация сборки OSGi

Итак, давайте посмотрим, как это работает на практике. Представьте себе сервис, который реализует постоянное хранение и выборку свойств. В Jitsi это то, что мы называем ConfigurationService, и это выглядит следующим образом:

package net.java.sip.communicator.service.configuration;

public interface ConfigurationService
{
  public void setProperty(String propertyName, Object property);
  public Object getProperty(String propertyName);
}

Очень простая реализация ConfigurationService выглядит, например, следующим образом:

package net.java.sip.communicator.impl.configuration;

import java.util.*;
import net.java.sip.communicator.service.configuration.*;

public class ConfigurationServiceImpl implements ConfigurationService
{
  private final Properties properties = new Properties();

  public Object getProperty(String name)
  {
    return properties.get(name);
  }

  public void setProperty(String name, Object value)
  {
    properties.setProperty(name, value.toString());
  }
}

Обратите внимание, как сервис определен в пакете net.java.sip.communicator.service, а реализация - в net.java.sip.communicator.impl. Все сервисы и реализации разнесены в Jitsi по этим двум пакетам. OSGi позволяет, чтобы в сборках только некоторые пакеты были видны за пределами своего собственного архива JAR, так что такое разделение облегчает сборкам экспортировать только пакеты сервисов и оставлять их реализации скрытыми.

Последнее, что нам нужно сделать с тем, чтобы можно было начать использовать нашу реализацию, это зарегистрировать ее в BundleContext и указать, что она является реализацией ConfigurationService. Вот как это происходит:

package net.java.sip.communicator.impl.configuration;

import org.osgi.framework.*;
import net.java.sip.communicator.service.configuration;

public class ConfigActivator implements BundleActivator
{
  public void start(BundleContext bc) throws Exception
  {
    bc.registerService(ConfigurationService.class.getName(), // имя сервиса
         new ConfigurationServiceImpl(), // реализация сервиса
         null);
  }
}

После того, как класс ConfigurationServiceImpl зарегистрирован в BundleContext, другие сборки могут начать его использовать. Ниже приведен пример, показывающий, как некоторая случайным образом выбранная сборка может использовать наш конфигурационный сервис:

package net.java.sip.communicator.plugin.randombundle;

import org.osgi.framework.*;
import net.java.sip.communicator.service.configuration.*;

public class RandomBundleActivator implements BundleActivator
{
  public void start(BundleContext bc) throws Exception
  {
    ServiceReference cRef = bc.getServiceReference(
                              ConfigurationService.class.getName());
    configService = (ConfigurationService) bc.getService(cRef);

    // И это все! У нас есть ссылка на реализацию сервиса
    // и мы готовы запустить сохраненные свойства:
    configService.setProperty("propertyName", "propertyValue");
  }
}

Еще раз обратите внимание на пакет. В net.java.sip.communicator.plugin мы сохраняем сборки, которые используют сервисы, определяемые другими сборками, но в которых ничего самостоятельно не экспортируется и не реализуется. Хорошим примером таких плагинов является конфигурационные формы: Они служат дополнениями к пользовательскому интерфейсу Jitsi, которые позволяют пользователям выполнять некоторые конкретные настройки приложения. Когда пользователи изменяют настройки, конфигурационные формы взаимодействуют с ConfigurationService или напрямую со сборками, ответственными за некоторую функциональную возможность. Впрочем, никакой из других сборок никогда не потребуется взаимодействовать с ними каким-либо другим образом (рис. 10.2).

Рис.10.2: Структура сервиса

10.3. Собираем и запускаем сборку

Теперь, когда мы знаем, как в сборке писать код, наступил момент поговорить о формировании пакетов. Когда сборки работают, им надо указать три различные сущности среды OSGi: пакеты Java, которые они делают доступными для других (например, экспортируемые пакеты), пакеты, которые они хотели бы использовать из сборок (т.е. импортируемые пакеты), а также имя их класса BundleActivator. В сборках это делается при помощи манифеста JAR-файла, который используется при установке сборки.

Для сервиса ConfigurationService, который мы определили выше, файл манифеста может выглядеть следующим образом:

Bundle-Activator: net.java.sip.communicator.impl.configuration.ConfigActivator
Bundle-Name: Configuration Service Implementation
Bundle-Description: A bundle that offers configuration utilities
Bundle-Vendor: jitsi.org
Bundle-Version: 0.0.1
System-Bundle: yes
Import-Package: org.osgi.framework,
Export-Package: net.java.sip.communicator.service.configuration

После создания манифеста файла JAR, мы готовы создать саму сборку. В Jitsi мы используем Apache Ant, с помощью которого выполняются все задачи, необходимые для создания сборки. Для того, чтобы добавить сборку в процедуру создания Jitsi, вам нужно отредактировать файл build.xml в корневом каталоге проекта. JAR-файлы сборки создаются в конце файла build.xml там, где указываются задачи (цели) bundle-xxx. Для того, чтобы собрать наш конфигурационный сервис, нам понадобится следующее:

<target name="bundle-configuration">
 <jar destfile="${bundles.dest}/configuration.jar" manifest=
    "${src}/net/java/sip/communicator/impl/configuration/conf.manifest.mf" >

    <zipfileset dir="${dest}/net/java/sip/communicator/service/configuration"
        prefix="net/java/sip/communicator/service/configuration"/>
    <zipfileset dir="${dest}/net/java/sip/communicator/impl/configuration"
        prefix="net/java/sip/communicator/impl/configuration" />
  </jar>
</target>

Как вы можете видеть, задача в Ant просто создает JAR-файл используя для этого наш конфигурационный манифест и добавляет к нему пакеты configuration из иерархий service и impl. Теперь единственное, что нам нужно сделать, это чтобы Felix их загрузил.

Мы уже упоминали, что Jitsi является простым набором сборок OSGi. Когда пользователь запускает приложение, он, на самом деле, запускает Felix со списком сборок, которые необходимо загрузить. Вы можете найти этот список в нашем каталоге lib, внутри файла с именем felix.client.run.properties. Felix запускает сборки в том порядке, который определяется уровнями запуска: гарантируется, что сборки некоторого конкретного уровня будут запущены перед тем, как начнется загрузка следующего уровня. Хотя на примере кода, приведенного выше, это и не видно, настройки нашего конфигурационного сервиса сохраняются в файлах, поэтому нужно использовать наш файл FileAccessService, находящийся внутри файла fileaccess.jar. Так что мы проверяем, что ConfigurationService запускается после FileAccessService:

⋮    ⋮    ⋮
felix.auto.start.30= \
  reference:file:sc-bundles/fileaccess.jar

felix.auto.start.40= \
  reference:file:sc-bundles/configuration.jar \
  reference:file:sc-bundles/jmdnslib.jar \
  reference:file:sc-bundles/provdisc.jar \
⋮    ⋮    ⋮

Если вы посмотрите на файл felix.client.run.properties, вы увидите в начале список следующих пакетов:

org.osgi.framework.system.packages.extra= \
  apple.awt; \
  com.apple.cocoa.application; \
  com.apple.cocoa.foundation; \
  com.apple.eawt; \
⋮    ⋮    ⋮

В этом списке Felix-у указывается, какие пакеты, указываемые в системном пути classpath, должны быть доступными для сборок. Это означает, что пакеты, которые есть в этом списке, могут импортироваться сборками (т.е. могут быть добавлены в их заголовок манифеста Import-Package) без какого-либо явного указания на экспорт в любых других сборках. В списке, в основном, указываются пакеты, которые относятся к частям JRE, связанными с определенными особенностями ОС, и разработчикам Jitsi редко требуется добавлять туда новые пакеты; в большинстве случаев пакеты делаются доступными сразу целыми сборками.

10.4. Сервис провайдера протоколов

ProtocolProviderService в Jitsi определяет то, как будут себя вести все реализации протоколов. Это интерфейс, который в других сборках (например, в пользовательском интерфейсе) будет использоваться в случаях, когда нужно отправлять и получать сообщения, совершать звонки и обмениваться файлами через сети, к которым Jitsi подключен.

Все интерфейсы сервисов протоколов можно найти в пакете net.java.sip.communicator.service.protocol. Есть много реализаций сервиса, по одной для каждого поддерживаемого протокола, и все они хранятся в net.java.sip.communicator.impl.protocol.protocol_name.

Давайте начнем с каталогом service.protocol. Наиболее интересным фрагментом является интерфейс ProtocolProviderService. Всякий раз, когда нужно выполнить задачу, связанную с использованием протоколов, нужно искать реализацию этого сервиса в BundleContext. Сервис и его реализации позволяют Jitsi подключаться к любой из поддерживаемых сетей, получать статус и подробности соединения, а главное — получать ссылки на классы, в которых реализованы конкретные коммуникационные задачи, например, чат и осуществление звонков.

10.4.1. Наборы операций

Как мы уже упоминали ранее, ProtocolProviderService необходим для единообразного использования различных коммуникационных протоколов и сведения их различий к минимуму. Хотя это, в частности, исключительно просто для тех функций, которые используются во всех протоколах, например, функция отправки сообщений, все становится гораздо сложнее для тех задач, которые поддерживаются только в некоторых протоколах. Иногда эти различия связаны с самим сервисом: например, большинство сервисов SIP не поддерживают списки контактов, хранящихся на сервере, хотя это относительно хорошо поддерживаемая функция во всех других протоколах. Другим хорошим примером являются сервисы MSN и AIM: в свое время ни в одном из них не было возможности оставлять сообщения абонентам, не находящимся в сети, хотя в остальных сервисах эта возможность была (с тех пор все изменилось).

Суть заключается в том, что в нашем ProtocolProviderService должен быть способ обработки этих различий с тем, чтобы другие сборки, например, графический интерфейс, действовали согласовано; нет смысла добавлять в AIM contact кнопку вызова, если, в действительности, нет возможности делать вызов.

В качестве спасательного средства используется OperationSets (рис. 10.3). Неудивительно, что есть набор операций и предлагается интерфейс, который сборки Jitsi используют для управления реализациями протоколов. Методы, которые вы можете найти в интерфейсе набора операций, является тем, что касается конкретных возможностей.

В OperationSetBasicInstantMessaging, например, содержатся методы для создания и отправки мгновенных сообщений и регистрации слушателей (listeners), которые позволяют Jitsi находить сообщения, которые он получает. В другом примере, OperationSetPresence, есть методы запроса статуса контактов в вашем списке и установки собственного статуса. Поэтому, когда графический интерфейс обновляет статус отображаемого контакта или отправляет сообщение для конкретного контакта, он сначала может запросить у соответствующего провайдера, поддерживаются ли контакты и передача сообщений. К методам, которые в ProtocolProviderService определены для этой цели, относятся следующие:

public Map<String, OperationSet> getSupportedOperationSets();
public <T extends OperationSet> T getOperationSet(Class<T> opsetClass);

Наборы OperationSets должны быть сконструированы таким образом, чтобы была маловероятной такая ситуация, когда новый протокол, который мы добавим, имеет поддержку только некоторых из операций, которые определены в OperationSet. Например, в некоторых протоколах не поддерживается хранение списков контактов на сервере даже в случае, если в них пользователям разрешается запрашивать статус друг друга. Поэтому вместо того, чтобы в OperationSetPresence объединять управление хранением данных и использование списка друзей, мы также определили набор OperationSetPersistentPresence, который используется только с протоколами, в которых контакты можно хранить в Интернете. С другой стороны, мы еще не сталкивались с протоколом, в котором можно только посылать сообщения и нельзя их получать, поэтому такие операции, как отправка и получение сообщений, можно смело объединять.

Рис.10.3: Наборы операций

10.4.2. Аккаунты, фабрики и экземпляры провайдеров

Важной характеристикой сервиса ProtocolProviderService является то, что один экземпляр соответствует ровно одному аккаунту протокола. Поэтому в любой заданный момент времени у вас в BundleContext есть столько возможных реализаций сервисов, сколько у вас есть аккаунтов, зарегистрированных пользователем.

В этот момент вам может стать интересно, кто создает и регистрирует провайдеров протоколов. В этом участвуют две сущности. Во-первых, имеется фабрика ProtocolProviderFactory. Это сервис, который позволяет другим сборкам получать отдельные экземпляры провайдеров, а затем регистрировать их в качестве сервисов. Для каждого протокола есть одна фабрика и каждая фабрика отвечает за создание провайдеров для конкретного протокола. Реализации фабрики хранятся вместе с остальными внутренними частями протокола. Например, для SIP у нас есть net.java.sip.communicator.impl.protocol.sip.ProtocolProviderFactorySipImpl.

Второй сущностью, участвующей в создании аккаунта, является визард протокола (protocol wizard). В отличие от фабрик, визарды отделены от остальной части реализации протокола, поскольку они связаны с графическим пользовательским интерфейсом. Визард, который позволяет пользователям создавать аккаунты для SIP, можно, например, найти в net.java.sip.communicator.plugin.sipaccregwizz.

10.5. Медиасервис

Когда работа со связью осуществляется в режиме реального времени поверх IP, имеется один важный аспект, о котором нужно иметь представление: протоколы, например, SIP и XMPP, хотя и признаются многими как наиболее распространенные протоколы VoIP, являются, на самом деле, не тем, что переносит голос и видео через Интернет. Эта задача выполняется с помощью протокола реального времени Real-time Transport Protocol (RTP). SIP и XMPP только отвечают за подготовку всего того, что требуется для RTP, например, определения адреса, куда должны отправляться пакеты RTP, форматов, в которых должны быть закодированы аудио и видео (например, кодек), и т.д. На них также возлагается обязанность отслеживать присутствие пользователей, поддерживать собственное присутствие, выполнять телефонный вызов (делать звонок) и многое другое. Именно поэтому протоколы, например, SIP и XMPP, часто называют сигнальными протоколами.

Что это значит в контексте Jitsi? Ну, в первую очередь, это означает, что вы не отправитесь искать какой-нибудь код, в котором происходит обработка аудио или видео потоков в каком-нибудь из пакетов jitsi sip или jabber. Такой код находится в нашем медиасервисе MediaService. MediaService и ее реализация находятся в net.java.sip.communicator.service.neomedia и net.java.sip.communicator.impl.neomedia.

Почему "neomedia"?

«neo» в названии пакета NeoMedia указывает, что он заменяет аналогичный пакет, который мы использовали первоначально, и что затем нам потребовалось его полностью переписать. Это фактически одно из наших эмпирических правил, к которому мы пришли: вряд ли стоит тратить много времени на разработку приложения на 100% того, что потребуется в будущем. Просто нет возможности учесть все факторы, поэтому в любом случае вам обязательно позже потребуется делать изменения. Кроме того, вполне вероятно, что при кропотливом проектированию у вас повысится сложность решений, которые вам никогда не придется воспользоваться, поскольку сценарии, к которым вы будете готовы, никогда не возникнут.

Вдобавок к самому MediaService, есть два других интерфейса, которые особенно важны: MediaDevice и MediaStream.

10.5.1. Захват медиа, потоковая обработка и воспроизведение

Устройства MediaDevice представляют собой устройства захвата и воспроизведения медиапотока, которыми мы пользуемся во время разговора (рис. 10.4). Ваш микрофон и колонки, ваша гарнитура и ваша веб-камер являются примерами таких устройств MediaDevice, но они не единственные. Потоковая обработка на настольном компьютере и совместные звонки с захватом видео в Jitsi с вашего рабочего стола при использовании режима конференц-связи используют устройство AudioMixer для смешивания звука, который мы получаем от активных участников. Во всех случаях, устройства MediaDevice представляют собой лишь один тип MediaType. То есть, все они могут быть только аудио или видео , но не обоих типов. Это означает, что если, например, у вас есть веб-камера со встроенным микрофоном, то Jitsi видит его как два устройства: одно, которое может только снимать видео, и еще одно, которое может захватывать только звук.

Однако, только устройств недостаточно для того, чтобы делать телефонные или видео вызовы. В дополнение к воспроизведению и захвату медиапотока, необходимо также иметь возможность отправлять его по сети. Это тот момент, когда используются интерфейсы медиапотоков MediaStream. Интерфейс MediaStream является тем, что подключается к устройству MediaDevice вашего собеседника. Здесь работа осуществляется с входящими и исходящими пакетами, которыми вы обмениваетесь с собеседником в течение вызова.

Точно также как и с устройствами, один поток может быть ответственен только за один тип MediaType. Это означает, что в случае аудио/видео вызовов Jitsi должен создать два отдельных медиапотока, а затем подключить каждый к соответствующему аудио или видео устройству MediaDevice.

Рис.10.4: Медиапотоки для различных устройств

10.5.2. Кодеки

Другим важным понятием в потоковом медиа является то, что в MediaFormats, также известно как кодеки. По умолчанию большинство операционных систем позволяют записывать звук в формате 48 кГц PCM или в некотором подобном формате. Это то, что мы часто называем «необработанном аудио» («raw audio») и это тот вид аудио, который вы получаете в файлах WAV: отличное качество и огромный размер. Довольно непрактично пытаться передавать аудио через Интернет в формате PCM.

Это то, для чего предназначены кодеки: они позволяют вам представлять и транспортировать аудио и видео различным образом. Некоторые аудио кодеки, например, iLBC, 8KHz Speex или G.729, имеют низкие требования к пропускной способности, но звучат несколько приглушенно. Другие, например, широкополосные Speex и G.722, дают великолепное качество звука, но и требуют большей пропускной способности. Есть кодеки, которые пытаются обеспечить хорошее качество, сохраняя при этом требования к пропускной способности на разумном уровне. Хорошим примером такого кодека является популярный видео-кодек H.264. Компромисс здесь состоит в большом объеме расчетов, необходимых в процессе преобразования. Если вы используете Jitsi для видео вызовов в H.264, то вы обеспечите хорошее качество изображения и ваши требования к пропускной способности будут вполне разумными, но ваш процессор будет работать на максимуме.

Все это очень упрощенно, но идея в том, что выбор кодека всегда является выбором компромиссов. Вы либо жертвуете пропускной способностью, качеством, загрузкой процессора, либо их комбинацией. Людям, работающим с VoIP, редко нужно знать о кодеках больше.

10.5.3. Подключение к провайдерам протоколов

Протоколы в Jitsi, которые в настоящее время имеют поддержку аудио/видео, все используют наши медиасервисы Mediaservices одинаковым образом. Сначала они спрашивают MediaService об устройствах, доступных в системе:

public List<MediaDevice> getDevices(MediaType mediaType, MediaUseCase useCase);

Тип MediaType указывает, интересуют ли нас аудио или видео устройства. Параметр MediaUseCase в настоящее время рассматривается только в случае видео устройств. Это сообщает медиа сервису, хотим ли мы получить устройство, которые можно использовать при обычном вызове (MediaUseCase.CALL), в этом случае он возвращает список доступных веб-камер, или совместно используемые сессии настольного компьютера (MediaUseCase.DESKTOP), в этом случае он возвращает ссылки на настольные компьютеры пользователей.

Следующим шагом является получение списка форматов, доступных для конкретного устройства. Мы делаем это с помощью метода MediaDevice.getSupportedFormats:

public List<MediaFormat> getSupportedFormats();

Как только будет получен этот список, реализация протокола отправляет его другой стороне, которая вернет его подмножество с тем, чтобы указать, какие из них она поддерживает. Этот обмен также известен как модель Предложение/Ответ (Offer/Answer Model) и он часто использует протокол описания сеанса Session Description Protocol или некоторую другую его форму.

После обмена форматами и некоторыми номерами портов и IP-адресами, протоколы VoIP создают, настраивают и запускают медиапотоки MediaStreams. Грубо говоря, эта инициализация выполняется в следующих строках:

// Сначала создается коннектор потока, сообщающий медиасервису, какие сокеты
// должны использоваться, когда медиапоток транспортируется с помощью RTP, а
// управление потоком и статистика сообщений осуществляется с помощью RTCP
StreamConnector connector =  new DefaultStreamConnector(rtpSocket, rtcpSocket);
MediaStream stream = mediaService.createMediaStream(connector, device, control);

// MediaStreamTarget указывает адрес и порт, которые наш собеседник использует
// для медиапотока. В различных протоколах VoIP есть свои собственные способы 
// обмена этой информацией
stream.setTarget(target);

// Параметр MediaDirection сообщает потоку, откуда он поступает, куда он направляется,
// или и то и другое
stream.setDirection(direction);

// Затем мы задаем формат потока. Мы используем тот, который пришел первым в 
// списке, вернувшимся в ответе о согласовании сессии.
stream.setFormat(format);

// Наконец, мы готовы действительно начать получать медиапоток от нашего 
// медиаустройства и направлять его в интернет 
stream.start();

Теперь вы можете помахать в веб-камеру рукой, схватить микрофон и сказать: "Привет, мир!"

10.6. Сервис пользовательского интерфейса

До сих пор мы рассматривали те части Jitsi, которые имели дело с протоколами, отправкой и получением сообщений и осуществлении вызовов. Однако, Jitsi это, прежде всего, приложение, используемое реальными людьми и, поэтому, одним из наиболее важных аспектов является его пользовательский интерфейс. Большую часть времени пользовательский интерфейс использует сервисы, в виде которых представлены все остальные сборки в Jitsi. Однако есть некоторые случаи, когда все происходит наоборот.

Первое, что приходит на ум, это - плагины. Плагинам в Jitsi часто нужно иметь возможность взаимодействовать с пользователем. Это означает, что они должны открывать, закрывать, перемещать или добавлять компоненты в существующие окна и панели в интерфейсе пользователя. Это то место, где в игру вступает наш сервис UIService. Он позволяет управлять основным окном в Jitsi, а также тем, как наши иконки будут позволять пользователям управлять приложением из dock-меню в Mac OS X или из области уведомлений в Windows.

Кроме простого использования списка контактов, плагины также могут выполнять дополнительные действия. Хорошим примером такого плагина является плагин, реализующий в Jitsi поддержку шифрования чата (OTR). Нашей сборке OTR необходимо зарегистрировать несколько графических компонентов в различных частях пользовательского интерфейса. Она добавляет кнопку замка в окно чата и подраздел в меню всех контактов, которое открывается правой кнопкой мыши.

Хорошо то, что он может делать все это с помощью вызова нескольких метода. В активаторе OSGi для сборки OTR, т. е. в OtrActivator, содержатся следующие строки:

Hashtable<String, String> filter = new Hashtable();

// Регистрируем элемент меню, открывающегося правой кнопкой.
filter(Container.CONTAINER_ID,
    Container.CONTAINER_CONTACT_RIGHT_BUTTON_MENU.getID());

bundleContext.registerService(PluginComponent.class.getName(),
    new OtrMetaContactMenu(Container.CONTAINER_CONTACT_RIGHT_BUTTON_MENU),
    filter);

// Регистрируем элемент меню панели меню окна чата.
filter.put(Container.CONTAINER_ID,
           Container.CONTAINER_CHAT_MENU_BAR.getID());

bundleContext.registerService(PluginComponent.class.getName(),
           new OtrMetaContactMenu(Container.CONTAINER_CHAT_MENU_BAR),
           filter);

Как вы можете видеть, добавление компонентов в наш графический интерфейс пользователя просто сводится к регистрации сервисов OSGi. С другой стороны, наша реализация UIService выполняет поиск собственной реализации интерфейса PluginComponent. Всякий раз, когда она обнаруживает, что была зарегистрирована новая реализация, она получает ссылку на нее и добавляет ее в контейнер, указанный в фильтре сервиса OSGi.

Вот как это происходит в случае щелчка правой кнопкой мыши по пункту меню. В сборке пользовательского интерфейса имеется класс MetaContactRightButtonMenu, обрабатывающий щелчок правой кнопки мыши и в котором есть следующие строки:

// Поиск компонентов плагина, зарегистрированных через контекст сборки OSGI.
ServiceReference[] serRefs = null;

String osgiFilter = "("
    + Container.CONTAINER_ID
    + "="+Container.CONTAINER_CONTACT_RIGHT_BUTTON_MENU.getID()+")";

serRefs = GuiActivator.bundleContext.getServiceReferences(
        PluginComponent.class.getName(),
        osgiFilter);
// Проходим по всем плагинам, которые найдем, и добавляем их в меню.
for (int i = 0; i < serRefs.length; i ++)
{
    PluginComponent component = (PluginComponent) GuiActivator
        .bundleContext.getService(serRefs[i]);

    component.setCurrentContact(metaContact);

    if (component.getComponent() == null)
        continue;

    this.add((Component)component.getComponent());
}

И это все, что нужно сделать. Большинство окон, которые вы видите в Jitsi, делают то же самое: Они ищут контекст сборки для сервисов, реализующих интерфейс PluginComponent, который имеет фильтр, указывающий, что их следует добавить в соответствующий контейнер. Плагины, как путешественники добирающиеся автостопом, держат таблички с названиями мест, куда они хотят добраться, превращая окна Jitsi в водителей, которые их подбирают.

10.7. Усвоенные уроки

Когда мы начали работу над SIP Communicator, одним из наиболее распространенных критических замечаний или вопросом, который мы слышали, был следующий: «Почему вы используете язык Java? Разве вы не знаете, что он медленный? Вы никогда не сможете получить достойное качество аудио/видео звонков!». Миф «язык Java - медленный» даже был повторен потенциальными пользователями в качестве причины того, почему они придерживаются Skype вместо Jitsi. Но первый урок, который мы усвоили из нашей работы над проектом, состоит в том, что с эффективностью на языке Java проблем не больше, чем их бы было с языком C++ или другими нативными альтернативами.

Мы не будем делать вид, что решение о выборе языка Java стало результатом тщательного анализа всех возможных вариантов. Нам просто нужен был простой способ создать нечто, что бы работало на Windows, и Linux, а Java и Java Media Framework, как нам показалось, предлагают один из относительно простых способов добиться этого.

На протяжении многих лет у нас было мало причин, чтобы сожалеть об этом решении. Совсем наоборот: даже хотя язык Java не сделал проект полностью прозрачным, он помогает обеспечивать переносимость и 90% кода в Communicator SIP не меняется от одной операционной системы к другой. Сюда относятся все реализации стека протоколов (например, SIP, XMPP, RTP, и т.д.), т. к. они достаточно сложны. Возможность не беспокоиться о специфике OS в таких частях кода оказывается весьма полезной.

Кроме того, популярность языка Java, оказалось очень важной при создании нашего сообщества. Авторы, как таковые, являются дефицитным ресурсом. Людям должно нравиться приложение, они должны найти время и мотивацию - все это трудно объединить вместе. Им не требуется учить новый язык и, таким образом, это - преимущество.

В отличие от большинства ожиданий, недостаточная скорость работы при использовании языка Java редко была причиной перехода на нативный код. Большинство решений, связанных с использованием нативного кода, обуславливались интеграцией с ОС и тем, насколько язык Java давал нам возможность получать доступ к утилитам конкретной OS. Ниже мы обсудим три наиболее важных области, в которых язык Java себя не оправдал.

10.7.1. Звук Java Sound и звук PortAudio

Java Sound является интерфейсом API, используемым в Java по умолчанию для записи и воспроизведения звука. Он является частью среды времени выполнения и, следовательно, работает на всех платформах, где есть виртуальная машина Java. В первые годы Jitsi, когда это был проект SIP Communicator, использовался исключительно интерфейс JavaSound и из-за это у нас было немало неудобств.

Прежде всего, интерфейс API не предоставил нам возможность выбирать, какие аудио устройства используются. Это большая проблема. Кода люди используют свои компьютеры для аудио и видео звонков, они для того, чтобы получить максимально возможное качество, часто применяют улучшенные гарнитуры USB или другие звуковые устройства. Когда в компьютере есть несколько таких устройств, JavaSound выбирает среди всех аудио устройств то, которое в операционной системе рассматривается как используемое по умолчанию, и это во многих случаях не всегда хорошо. Многие пользователи хотели бы сохранить настройки всех других приложений, работающих по умолчанию с их звуковой картой, так, чтобы, например, они могли продолжать прослушивать музыку через колонки. Еще более важно то, что во многих случаях для SIP Communicator лучше передавать аудио уведомление о вызове на одно устройство, а фактический звук вызова на другое, что позволяет пользователю, даже если он не находиться перед компьютером, слышать сигнал вызова через колонки, а затем, когда он примет вызов, перейти к использованию гарнитуры.

Все это невозможно с Java Sound. Более того, в реализации для Linux используется OSS, что сегодня не рекомендуется использовать на большинстве дистрибутивов Linux.

Мы решили использовать альтернативную аудиосистему. Мы не хотели идти на компромисс с нашей мультиплатформенным решением и, если возможно, мы не хотели возиться со всем этим самостоятельно. Это та ситуация, когда чрезвычайно удобной оказалась система PortAudio [2].

Если Java самостоятельно не позволяет что-то делать, то следующим лучшим решением является использование кросс-платформенных проектов с открытым исходным кодом. Переход на проект PortAudio позволил нам реализовать поддержку лучшей настройки аудиопотоков так, как мы это описывали выше. Он также работает на Windows, Linux, Mac OS X, FreeBSD и других системах, для которых у нас не было времени создавать пакеты.

10.7.2. Захват и рендеринг видео

Видео для нас так же важно, как звук. Однако, это оказалось не так для создателей Java, поэтому по умолчанию в JRE нет интерфеса API, который позволяет выполнять захват или рендеринг видео. Некоторое время казалось, что таким API суждено было стать фреймворку Java Media Framework, до тех пор, пока фирма Sun не остановила его поддержку.

Естественно, мы начали искать альтернативу видео в стиле PortAudio, но на этот раз нам не так повезло. Сначала мы решили перейти на фреймворк LTI-CIVIL Кена Ларсона (Ken Larson) [3]. Это замечательный проект, и мы некоторое время его использовали [4]. Однако он оказался не слишком оптимальным при использовании в контексте коммуникаций реального времени.

Таким образом, мы пришли к выводу, что единственный способ обеспечить безупречную видеосвязь для Jitsi будет создание нативных грабберов и модулей рендеринга, которые мы должны были сделать самостоятельно. Это было не простое решение, поскольку оно подразумевало существенное увеличение сложности проекта и значительную нагрузку его поддержки, но у нас просто не было выбора: мы действительно хотели иметь качественную видеосвязь. И сейчас мы это делаем!

Наши нативные грабберы и модули рендеринга напрямую используют Video4Linux 2, QTKit и DirectShow/Direct3D на Linux, Mac OS X и Windows, соответственно.

10.7.3. Кодирование и декодирование видео

SIP Communicator, и, следовательно, Jitsi, с первых дней поддерживает видео-звонки. Это потому, что Java Media Framework позволял кодировать видео с использованием кодека H.263 и формата 176х144 (CIF). Те из вас, кто знает, как выглядит H.263 CIF, вероятно, сразу улыбнулись; мало кто из нас будет сегодня пользоваться приложением видео-чата в случае, если это все, что это приложение может предложить.

Для того чтобы обеспечить достойное качество, нам нужно было использовать другие библиотеки, например, FFmpeg. Кодирование видео, на самом деле, является одним из немногих мест, где производительности языка Java оказывается действительно недостаточно. То же самое касается других языков, о чем свидетельствует тот факт, что для того, чтобы обрабатывать видео наиболее эффективным способом, разработчики FFmpeg на самом деле в нескольких местах использовали Assembler.

10.7.4. Прочее

Есть много других мест, где мы решили, что для получения лучших результатов нам нужно перейти на нативный код. Одним из таких примеров являются уведомления системного трея Systray с Growl - на Mac OS X и с libnotify — на Linux. К числу других примеров относятся базы данных запросов контактов из Microsoft Outlook и Apple Address Book, определяющие IP-адрес источника в зависимости от назначения, в которых использовались существующие реализации кодеков для Speex и G.722, позволяющие захватывать скриншоты рабочего стола и транслирующие символы char в коды нажатия клавиш.

Важно то, что всякий раз, когда нам нужно было выбрать нативное решение, мы могли это сделать и мы это делали. Это подводит нас к следующему: С тех пор, как мы запустили проект Jitsi, мы исправили, добавили, или даже полностью переписывали различные его части, поскольку мы хотели, чтобы они выглядели, воспринимались и работали лучше. Тем не менее, мы никогда не пожалели ни о чем, что не было первоначально написано правильно. Когда мы сомневались, мы просто выбрали один из имеющихся вариантов и использовали его. Мы могли бы подождать, пока мы не узнали лучше, что мы делаем, но если бы мы так поступали, то сегодня не было никакого проекта Jitsi.

10.8. Благодарности

Огромное спасибо Яне Стамчевой (Yana Stamcheva) за создание всех схем для этой главы.

Примечания

  1. Чтобы непосредственно просматривать исходный код во время чтения главы, скачайте его с http://jitsi.org/source. Если вы используете Eclipse или NetBeans, вы можете скачать инструкцию о их настройке по ссылке http://jitsi.org/eclipse или http://jitsi.org/netbeans
  2. http://portaudio.com/
  3. http://lti-civil.org/
  4. В действительности, у нас до сих пор есть этот вариант, который используется как неосновной.

Creative Commons. Перевод был сделан в соответствие с лицензией Creative Commons. С русским вариантом лицензии можно ознакомиться здесь.

Сноски

  1. http://llvm.org
  2. http://clang.llvm.org
  3. Chris Lattner and Vikram Adve: "LLVM: A Compilation Framework for Lifelong Program Analysis & Transformation". Proc. 2004 International Symposium on Code Generation and Optimization (CGO'04), Mar 2004.

11.1 Краткое описание классической архитектуры компиляторов

Наиболее популярной архитектурой традиционных статических компиляторов (большинства компиляторов языка C) является архитектура трех фаз, главными элементами которой являются система предварительной обработки, оптимизатор и система генерации кода (Рисунок 11.1). Система предварительной обработки производит разбор исходного кода, проверяет его на наличие ошибок, и строит специфическое для языка дерево абстрактного синтаксического анализа (Abstract Syntax Tree - AST) с целью представления исходного кода. В некоторых случаях дерево абстрактного синтаксического анализа конвертируется в новое представление для оптимизации, после чего оптимизатор и система генерации кода работают с кодом.

Три основных компонента компилятора на основе архитектуры трех фаз
Рисунок 11.1: Три основных компонента компилятора на основе архитектуры трех фаз

Оптимизатор предназначен для проведения широкого круга преобразований с целью снижения времени исполнения кода, чаще всего путем исключения излишних расчетов, и обычно более или менее независим от языка программирования и целевой системы. Система генерации кода (также известная как кодогенератор) используется впоследствии для преобразования кода в набор инструкций целевой системы. В дополнение к генерации корректного кода, она должна генерировать качественный код, использующий нестандартные особенности поддерживаемой архитектуры. Стандартными механизмами системы генерации кода компилятора являются механизмы выбора инструкций, резервирования регистров и распределения инструкций.

Эта модель также отлично подходит к интерпретаторам и JIT-компиляторам. Виртуальная машина Java (JVM) также реализует данную модель, используя байткод при взаимодействии системы предварительной обработки и оптимизатора.

11.1.1 Последствия использования данной архитектуры

Наиболее важное достоинство данной архитектуры проявляется в том случае, когда от компилятора требуется поддержка нескольких языков программирования или нескольких целевых архитектур. Если компилятор использует стандартное представление кода при работе с оптимизатором, может быть разработана система предварительной обработки кода для любого языка программирования, для которого возможно преобразование кода в это представление, а также может быть разработана система генерации кода для любой целевой архитектуры, для которой может быть сгенерирован код на основе этого представления, как показано на Рисунке 11.2.

Многоцелевой компилятор
Рисунок 11.2: Многоцелевой компилятор

При использовании этой архитектуры доработка компилятора для поддержки нового языка (такого, как Algol или BASIC) требует реализации новой систем предварительной обработки кода, а оптимизатор и система генерации кода могут использоваться повторно. Если бы эти части не были разделены, реализация поддержки нового языка потребовала бы разработки нового компилятора, поэтому для поддержки N целевых архитектур и M языков программирования потребовалось бы M*N компиляторов.

Другим преимуществом архитектуры трех фаз (являющимся прямым следствием работы компилятора с множеством целевых архитектур) является то обстоятельство, что компилятор используется большим количеством программистов, чем в том случае, если бы он поддерживал только один язык программирования и одну целевую архитектуру. Для проекта с открытым исходным кодом это означает, что вокруг проекта сформируется большее сообщество потенциальных разработчиков, что обычно приводит к расширению возможностей и улучшению компилятора. По этой причине компиляторы с открытым кодом, поддерживаемые множеством сообществ (такие, как GCC) обычно генерируют более качественно оптимизированный машинный код, нежели компиляторы, поддерживаемые одним сообществом, такие, как FreePASCAL. Это утверждение не относится к пропиетарным компиляторам, качество которых напрямую зависит от бюджета проекта. Например, компилятор ICC компании Intel широко известен благодаря высокому качеству генерируемого кода, хотя он и используется ограниченным кругом лиц.

Заключительным важным достоинством архитектуры трех фаз является то обстоятельство, что навыки, требуемые для разработки системы предварительной обработки кода отличаются от навыков, требуемых для разработки оптимизатора и системы генерации кода. Разделение этих систем упрощает работу участника, поддерживающего одну из систем предварительной обработки кода. Хотя это больше относится к социальным условиям, нежели к техническим, на самом деле это очень важно в особенности для проектов с открытым исходным кодом, разработчики которых хотят снизить барьер для вхождения новых разработчиков в проект настолько, насколько это возможно.

11.2. Существующие реализации языков программирования

Хотя достоинства архитектуры трех фаз бесспорны и хорошо описаны в книгах по разработке компиляторов, на практике данные архитектурные решения практически никогда не были реализованы в полной мере. Рассматривая реализации языков программирования (в момент начала работы над LLVM) , вы можете обнаружить, что реализации таких языков, как Perl, Python, Ruby и Java не содержат общего кода. Более того, такие проекты, как Glasgow Haskell Compiler (GHC) и FreeBASIC позволяют генерировать машинный код для разных моделей центральных процессоров, но их реализации жестко привязаны к одному языку исходного кода, который они поддерживают. Существует также широкий круг технологий компиляции специального назначения, на основе которых реализуются JIT-компиляторы для работы с изображениями, регулярными выражениями, драйверами графических карт, а также работы в других областях, требующих интенсивного использования центрального процессора.

Следует отметить тот факт, что существуют как минимум три истории успеха, связанные с этой архитектурой, а первой из них являются виртуальные машины Java и .NET. Эти системы предоставляют JIT-компилятор, окружение времени исполнения и достаточно хорошо проработанный формат байткода. Это означает то, что любой язык, который может быть скомпилирован в формат байткода (и таких языков множество3), может использовать преимущества оптимизатора и JIT наряду с использованием окружения времени исполнения. При этом стоит учесть тот факт, что эти реализации ограничивают гибкость выбора окружений времени исполнения: обе реализации осуществляют эффективную JIT-компиляцию, сборку мусора и используют проработанную объектную модель. В итоге такой подход позволяет получить приемлемую производительность в случаях, когда компилируемые языки, такие, как язык C, не полностью подходят по критериям (в качестве примера можно привести проект LLJVM).

Второй историей успеха является скорее всего самый неудачный и при этом самый популярный подход повторного использования технологий компилятора: преобразование переданного исходного кода в исходный код языка C (или какого-либо другого языка) и обработка его с помощью одного из существующих компиляторов C. Этот подход позволяет повторно использовать оптимизатор и генератор кода и является достаточно гибким решением, позволяющим контролировать окружение времени исполнения, а также данное решение позволяет участникам разработки системы предварительной обработки кода без лишних сложностей понять, реализовать и поддерживать ее. К сожалению, данный подход осложняет эффективную реализацию обработки исключений, позволяет использовать только ограниченные возможности отладки, замедляет процесс компиляции и может приводить к проблемам при работе с языками, требующими наличия гарантированных хвостовых вызовов (или других функций, не поддерживаемых языком C).

Завершающей список успешной реализацией данной модели является компилятор GCC4. GCC поддерживает множество систем предварительной обработки кода и разрабатывается активным и многочисленным сообществом. В течение длительного промежутка времени GCC был компилятором языка C с поддержкой различных целевых архитектур и с наличием поддержки нескольких дополнительных языков программирования, реализованной с использованием сложных приемов. По прошествии лет сообщество разработчиков GCC медленно реализовывало более совершенную архитектуру. В версии GCC 4.4 используется отдельное представление кода оптимизатором (известное как "Кортежи GIMPLE" - "GIMPLE Tuples"), которое в большей степени отделено от представления системы предварительной обработки кода, чем использующееся ранее. Также существуют системы предварительной обработки кода для языков Fortran и Ada, использующие необработанные деревья стандартного синтаксического анализа (AST).

Хотя эти продукты и были успешны, возможности их использования жестко ограничены, так как они проектировались как монолитные приложения. В качестве примера можно упомянуть о том, что компилятор GCC невозможно встроить в другие приложения, его невозможно использовать в качестве JIT-компилятора или интрепретатора, а также невозможно выделить и повторно использовать отдельные части GCC без задействования всего компилятора. Разработчикам, желающим использовать систему предварительной обработки кода для языка C++ из GCC в качестве инструмента для генерации документации, индексации кода, рефакторинга и статического анализа приходилось использовать GCC как монолитное приложение, выводящее интересующую их информацию в формате XML или разрабатывать расширения для использования стороннего кода в GCC.

Существует множество причин по которым отдельные части GCC не могут использоваться повторно в качестве библиотек, включающее в себя такие причины, как чрезмерное использование глобальных переменных, недостаточное использование инвариантов, некачественно проработанные структуры данных, распределенная кодовая база и использование макросов, которые затрудняют компиляцию кода для поддержки более чем одной комбинации из системы предварительной обработки кода и системы генерации кода. Наиболее сложные для исправления проблемы являются следствием недоработок в первоначальной архитектуре и возраста компилятора. В особенности GCC страдает от недоработок в распределении уровней и создании абстракций: система генерации кода получает доступ к дереву стандартного синтаксического анализа системы предварительной обработки кода для генерации отладочной информации, система предварительной обработки кода генерирует структуры данных для системы генерации кода и весь компилятор зависит от глобальных структур данных, изменяемых с помощью интерфейса командной строки.

Сноски

  1. http://en.wikipedia.org/wiki/List_of_JVM_languages
  2. Сейчас название расшифровывается как "GNU Compiler Collection".

11.3. Представление кода в LLVM: LLVM IR

После краткого экскурса в историю, давайте перейдем к подробному рассмотрению проекта LLVM: наиболее важным аспектом его архитектуры является использование промежуточного представления кода LLVM (LLVM Intermediate Representation - IR), являющегося формой для представления кода компилятором. Представление LLVM IR спроектировано для проведения промежуточного анализа и преобразований, которые осуществляются системой оптимизации компилятора. Данное представление было разработано с учетом множества специфических задач, включающих в себя поддержку простых оптимизаций времени исполнения, кроссфункицональных и межпроцедурных оптимизаций, анализа всей программы, агрессивных преобразований реструктурирования, и.т.д. При этом наиболее важным является тот факт, что это представление само является языком с четко заданной семантикой. В качестве конкретного примера ниже приведено содержимое файла с расширением .ll:

define i32 @add1(i32 %a, i32 %b) {
entry:
  %tmp1 = add i32 %a, %b
  ret i32 %tmp1
}

define i32 @add2(i32 %a, i32 %b) {
entry:
  %tmp1 = icmp eq i32 %a, 0
  br i1 %tmp1, label %done, label %recurse

recurse:
  %tmp2 = sub i32 %a, 1
  %tmp3 = add i32 %b, 1
  %tmp4 = call i32 @add2(i32 %tmp2, i32 %tmp3)
  ret i32 %tmp4

done:
  ret i32 %b
}

Это представление LLVM IR соответствует следующему коду на языке C, иллюстрирующему два варианта сложения целых чисел:

unsigned add1(unsigned a, unsigned b) {
  return a+b;
}

// Не самый эффективный способ сложения двух чисел.
unsigned add2(unsigned a, unsigned b) {
  if (a == 0) return b;
  return add2(a-1, b+1);
}

Как вы видите в этом примере, представление LLVM IR является низкоуровневым виртуальным набором инструкций, подобным RISC. Как и в реальном наборе инструкций RISC, в нем поддерживаются линейные последовательности таких простых инструкций, как инструкции сложения, вычитания, сравнения и ветвления. Используются трехадресная форма инструкций, поэтому они принимают некоторое количество входных данных и помещают результат в отдельный регистр.5 Представление LLVM IR поддерживает метки и в общем случае выглядит как необычная форма ассемблерного кода.

В отличие от большинства наборов инструкций RISC, в LLVM применяется строгая типизация на основе простой системы типов (т.е., тип i32 соответствует 32-битному целочисленному значению, тип i32** соответствует указателю на указатель на 32-битное целочисленное значение), а также некоторые системные особенности заменены абстракциями. Например, соглашение о вызове функций заменено абстракцией на основе инструкций call и ret с очевидными аргументами. Другим кардинальным отличием от машинного кода является использование представлением LLVM IR бесконечного числа временных переменных с именами, начинающимися с символа %, вместо ограниченного набора именованных регистров.

Хотя представление LLVM IR и реализовано в форме языка, оно может быть представлено в виде трех изоморфных форм: в текстовом формате, описанном выше, в виде структур для хранения в памяти, исследуемых и изменяемых оптимизатором, и в виде эффективного и сжатого "биткода" для хранения на диске. Проект LLVM также предоставляет инструменты для преобразования хранящегося на диске представления из текстового в бинарный формат: llvm-as преобразует текстовый файл с расширением .ll в сжатый бинарный файл биткода с расширением .bc, а llvm-dis преобразует файл с расширением .bc в файл с расширением .ll.

Промежуточное представление кода интересно потому, что оно активно используется оптимизатором: в отличие от системы предварительной обработки кода и системы генерации кода компилятора, на работу оптимизатора не влияет ни исходный язык программирования, ни выбранная целевая архитектура. С другой стороны, оптимизатор должен хорошо обслуживать обе эти системы: он должен быть спроектирован таким образом, чтобы системе предварительной обработки кода было легче генерировать промежуточное представление, а также оставлять возможность для выполнения важных оптимизаций под заданные реальные архитектуры.

11.3.1. Разработка алгоритмов оптимизаций для LLVM IR

Для интуитивного понимания принципа работы оптимизаций полезно рассмотреть несколько примеров. Существует множество типов выполняемых компилятором оптимизаций, поэтому сложно обозначить принцип решения случайной задачи. При этом процесс выполнения большинства оптимизаций может быть разделен на три шага:

Наиболее тривиальной оптимизацией является оптимизация арифметических тождеств с помощью шаблонов, например: для любого целочисленного значения X, X-X является 0, X-0 является X, (X*2)-X является X. Первоочередным является вопрос о том, как это будет выглядеть в представлении LLVM IR. Некоторые примеры приведены ниже:

:    :    :
%example1 = sub i32 %a, %a
:    :    :
%example2 = sub i32 %b, 0
:    :    :
%tmp = mul i32 %c, 2
%example3 = sub i32 %tmp, %c
:    :    :

Для данного типа "очевидных" преобразований LLVM предоставляет интерфейс упрощения инструкций, используемый различными преобразованиями более высоких уровней. Эти преобразования находятся в функции SimplifySubInst и выглядят следующим образом:

// X - 0 -> X
if (match(Op1, m_Zero()))
  return Op0;

// X - X -> 0
if (Op0 == Op1)
  return Constant::getNullValue(Op0->getType());

// (X*2) - X -> X
if (match(Op0, m_Mul(m_Specific(Op1), m_ConstantInt<2>())))
  return Op1;

...

return 0;  // Совпадений не найдено, возвращается нулевое значение для указания на отсутствие преобразований.

В данном коде значения Op0 и Op1 привязаны к левому и правому операндам инструкции целочисленного вычитания (важно отметить, что эти обозначения не должны обязательно сохраняться для инструкций, работающих с числами с плавающей точкой IEEE!) LLVM реализован с использованием языка C++, который не является широко известным благодаря своим функциям для работы с шаблонами (в отличие от таких функциональных языков, как Objective Caml), но предоставляет чрезвычайно обобщенную систему шаблонов, которая позволяет нам реализовать подобные механизмы. Функция match и функции с префиксом m_ позволяют нам осуществлять операции декларативного сопоставления шаблонов в коде представления LLVM IR. Например, функция m_Specific указывает на совпадение только тогда, когда значение выражения слева в инструкции умножения является таким же, как и Op1.

Во всех трех случаях используется сопоставление шаблонов и функция возвращает выражение для замены, если упрощение возможно, либо нулевой указатель, если упрощение невозможно. Данная функция (SimplifyInstruction) вызывается диспетчером, который обходит коды инструкций, выбирая соответствующую функцию обработки для каждого из кодов. Она вызывается из различных механизмов оптимизации. Простейший пример использования выглядит следующим образом:

for (BasicBlock::iterator I = BB->begin(), E = BB->end(); I != E; ++I)
  if (Value *V = SimplifyInstruction(I))
    I->replaceAllUsesWith(V);

Данный код просто обходит все инструкции в блоке, проверяя возможность упрощения каждой из них. В случае возможности упрощения (функция SimplifyInstruction возвращает ненулевой указатель), используется метод replaceAllUsesWith для замены всех операций в коде на их упрощенную форму.

Сноски

  1. В отличие от набора инструкций с двумя адресами, как в случае с архитектурой X86, где происходит деструктивное обновление содержимого исходного регистра, или систем с одним адресом, в которых принимается один опреранд и производится работа с накопителем или с вершиной стека в соответствующих системах.

11.4. Реализация архитектуры трех фаз в LLVM

В компиляторе на основе LLVM система предварительной обработки кода осуществляет разбор, проверку и выявление ошибок в переданном коде, после чего преобразует разобранный код в представление LLVM IR (обычно, но не всегда путем формирования дерева стандартного синтаксического анализа (AST) и преобразования его в представление LLVM IR). Это представление может быть подвергнуто ряду операций по анализу и оптимизации для улучшения качества кода, после чего оно передается генератору кода для формирования машинного кода для необходимой архитектуры, как показано на Рисунке 11.3. Это достаточно линейная реализация архитектуры трех фаз, но простое объяснение демонстрирует всю мощь и гибкость, которой обладает архитектура LLVM благодаря использованию представления LLVM IR.

Реализация архитектуры трех фаз в LLVM
Рисунок 11.3: Реализация архитектуры трех фаз в LLVM

11.4.1 Представление LLVM IR является завершенным представлением кода

На самом деле представление LLVM IR является одновременно хорошо описанным и единственным интерфейсом для оптимизатора. Это означает, что все необходимые вам для разработки системы предварительной обработки кода знания заключаются в понимании устройства, принципов работы и используемых данных представления LLVM IR. Так как представление LLVM IR имеет текстовую форму, возможно и разумно создавать систему предварительной обработки кода, которая выводит представление LLVM IR в текстовой форме, после чего использует каналы Unix для передачи его выбранному оптимизатору и генератору кода.

Может показаться удивительным, но описанная выше возможность является оригинальной особенностью LLVM и одной из причин его успеха при работе в составе большого количества приложений. Даже очень успешный и обладающий относительно продуманной архитектурой компилятор GCC не имеет данной особенности: его промежуточное представление кода GIMPLE не является завершенным. В качестве простейшего примера следует упомянуть о том, что при генерации отладочной информации DWARF генератором кода из состава GCC, производится обход дерева исходного кода. Представление GIMPLE использует "кортежи" для обозначения операций в коде, но (по крайней мере в GCC 4.5) операнды все также обозначаются с помощью ссылок на дерево исходного кода.

В итоге разработчикам систем предварительной обработки кода необходимо обладать знаниями о том, как создается структуры дерева исходного кода GCC, а также о GIMPLE для их разработки. Генератор кода GCC обладает аналогичными недостатками, поэтому разработчикам приходится также разбираться в принципах его работы. Наконец, в GCC не предусмотрено возможности сохранения "завершенного представления кода" или способа чтения или записи представления GIMPLE (и соответствующих структур данных, формирующих представление кода) в текстовой форме. В результате эксперименты с GCC становятся относительно сложными, что ведет к уменьшению количества систем предварительной обработки кода.

11.4.2. LLVM является набором библиотек

Помимо представления LLVM IR, важным аспектом архитектуры LLVM является лежащий в его основе набор библиотек, а не монолитный компилятор с интерфейсом командной строки в случае GCC или сложная виртуальная машина в случае JVM или .NET. LLVM является инфраструктурой, набором полезных используемых в компиляторах технологий, которые могут быть использованы для решения специфических задач (таких, как включение компилятора языка C или оптимизатора в состав конвейера обработки спецэффектов). Эта возможность является одним из самых мощных и при этом плохо понимаемых архитектурных решений.

Давайте рассмотрим архитектуру оптимизатора, воспользовавшись примером: он читает представление LLVM IR, разделяет его на части, после чего генерирует представление LLVM IR, которое в теории может выполняться быстрее исходного. В LLVM (как и во многих других компиляторах) оптимизатор реализован в виде конвейера из разделенных фаз оптимизации, каждая из которых обрабатывает переданное представление и имеет возможность его модификации. Стандартными примерами оптимизаций являются: обработка inline-функций (при которой тела функций подставляются в места их вызовов), объединение выражений, оптимизация кода циклов, и.т.д. В зависимости от уровня оптимизаций выполняются различные фазы: например, при уровне -O0 (без оптимизаций) компилятор Clang не использует фазы вообще, а при уровне -O3 он использует серию из 67 фаз в ходе работы оптимизатора (в версии LLVM 2.8).

Алгоритм работы каждой фазы разрабатывается в виде отдельного класса C++, наследуемого (не напрямую) от класса Pass. Для большинства фаз используются отдельные файлы с расширением .cpp и их подкласс класса Pass объявляется в анонимном пространстве имен (что делает его полностью недоступным из файла объявления). Для того, чтобы было возможным использование фазы, у кода вне класса должна быть возможность использовать его, поэтому единственная функция (создания класса) экспортируется из файла. Немного упрощенный пример алгоритма фазы для пояснения представлен ниже6:

namespace {
  class Hello : public FunctionPass {
  public:
    // Печать имен функций из представления LLVM IR, подвергающихся оптимизации.
    virtual bool runOnFunction(Function &F) {
      cerr << "Hello: " << F.getName() << "\n";
      return false;
    }
  };
}

FunctionPass *createHelloPass() { return new Hello(); }

Как упоминалось ранее, оптимизатор LLVM позволяет использовать множество различных фаз, стили реализации алгоритмов каждой из которых идентичны. Эти алгоритмы компилируются в одни или несколько файлов с расширением .o, которые затем преобразуются в ряд статических библиотек (файлы с расширением .a в Unix-системах). Эти библиотеки предоставляют все виды функций для анализа и преобразования кода, а фазы по возможности объединяются: они могут использоваться отдельно или явно объявлять зависимости в том случае, если их работа зависит от результатов какого-либо анализа кода из другой фазы. При выполнении данной последовательности фаз система PassManager из состава LLVM использует явную информацию о зависимостях для удовлетворения этих зависимостей и оптимизации процесса выполнения фаз.

Библиотеки и абстрактные возможности замечательно проработаны, но они на самом деле не решают проблем. Интересно рассмотреть случай, когда кто-либо хочет создать инструмент, в котором возможно использование технологии компиляции, в частности, JIT-компилятор для языка обработки изображений. Разработчик этого JIT-компилятора сформулировал ряд условий: например, язык обработки изображений очень восприимчив к задержке при компиляции и имеет некоторые характерные свойства, поэтому необходимо произвести оптимизацию производительности.

Архитектура оптимизатора LLVM, основанная на библиотеках, позволяет нашему разработчику выбрать последовательность исполнения фаз, а также выбрать те фазы, которые необходимы в случае обработки изображений: если весь код находится в одной большой функции, нет смысла тратить время на обработку inline-функий. Если указатели используются крайне редко, не стоит беспокоиться об анализе ссылок и оптимизации использования памяти. Однако, несмотря на наши усилия, LLVM не сможет волшебным образом решить все задачи оптимизации! Так как система оптимизации разделена на модули и система PassManager не обладает информацией о внутренних алгоритмах фаз, у разработчика появляется возможность реализации своих собственных фаз оптимизации для данного языка, способных сгладить неточности работы оптимизатора LLVM в плане явных специфических для данного языка возможностей оптимизации. На Рисунке 11.4 показан простой пример нашей гипотетической системы обработки изображений XYZ:

Гипотетическая система XYZ, использующая LLVM
Рисунок 11.4: Гипотетическая система XYZ, использующая LLVM

Как только выбран набор оптимизаций (а также приняты подобные решения для генератора кода) компилятор для языка обработки изображений формируется в виде исполняемого файла или динамической библиотеки. Так как единственной ссылкой на алгоритм фазы оптимизации является простая функция create, объявляемая в каждом файле с расширением .o, а также оптимизаторы находятся в статических библиотеках с расширением .a, с приложением связывается код только тех фаз оптимизации, которые действительно используются, а не всех фаз оптимизации, доступных для LLVM. В нашем примере выше есть ссылки на код фаз PassA и PassB, поэтому они связываются с кодом приложения. Так как алгоритм фазы PassB использует алгоритм фазы PassD для проведения анализа, код фазы PassD также связывается с кодом приложения. Однако, так как фаза PassC (и множество других фаз оптимизации) не используется, код данной фазы не связывается с кодом приложения для обработки изображений.

В этой ситуации очевидна вся мощь архитектуры LLVM на основе библиотек. Это простое архитектурное решение позволяет LLVM предоставлять большое количество возможностей, некоторые из которых могут быть полезны только для определенного круга разработчиков, не лишая возможности использования библиотек тех, кому нужно выполнять только простейшие задачи. Напротив, в классических компиляторах оптимизаторы реализованы в виде тесно связанного кода большого объема, что осложняет его разделение на части, определение назначения его частей и ускорение его работы. В LLVM вы можете понять как работают отдельные оптимизаторы, не зная о том, как функционирует вся система.

Эта архитектура на основе библиотек также является причиной непонимания многими людьми принципов работы LLVM: библиотеки LLVM имеют множество возможностей, но они на самом деле ничего не делают сами по себе. Разработчик клиента для этих библиотек (например, компилятора языка C Clang) решает то, как они будут использоваться лучшим образом. Это тщательное разделение уровней, функций и внимание к разделению компонентов обуславливает возможность использования оптимизатора LLVM в таком широком диапазоне различных приложений для различных целей. Также тот факт, что LLVM позволяет использовать JIT-компиляию не говорит о том, что каждый клиент использует ее.

Сноски

  1. Для ознакомления с подробностями обратитесь к руководству "Writing an LLVM Pass manual" по адресу http://llvm.org/docs/WritingAnLLVMPass.html.

11.5. Архитектура многоцелевого генератора кода LLVM

Генератор кода LLVM преобразует код представления LLVM IR в машинный код для заданной целевой архитектуры. С другой стороны задачей генератора кода является формирование максимально качественного машинного кода для каждой из архитектур. В идеальном случае каждый генератор кода должен генерировать полностью отличающийся код для своей архитектуры, но с другой стороны генераторы кода для каждой целевой архитектуры решают аналогичные задачи. Например, каждая архитектура требует помещения значений в регистры и хотя каждая архитектура имеет отдельный файл со списком регистров, алгоритмы должны использоваться совместно там, где это возможно.

Аналогично подходу к реализации оптимизатора, генератор кода LLVM разделяет задачу по генерации кода на отдельные фазы - выбор инструкций, резервирование регистров, планирование использования регистров, оптимизация кода и генерация кода, а также предоставляет большое количество встроенных фаз, выполняющихся по умолчанию. Автор поддержки целевой архитектуры может выбрать стандартные фазы, заменить стандартные фазы или реализовать специфические для архитектуры фазы в случае необходимости. Например, система генерации кода для платформы x86 использует планирование распределения регистров, снижающее их использование, так как в данной архитектуре предусмотрено малое количество регистров, при этом система генерации кода для архитектуры PowerPC использует оптимизацию задержек, так как данная архитектура предусматривает большое количество регистров. Система генерации кода для архитектуры x86 использует специальную фазу генерации кода для обработки стека чисел с плавающей точкой x87, а система генерации кода для архитектуры ARM использует специальную фазу генерации кода для размещения наборов констант внутри функций по мере необходимости. Эта гибкость позволяет разработчикам систем генерации кода формировать качественный код без необходимости разработки с нуля всего генератора кода для их целевой архитектуры.

11.5.1. Файлы описания целевой архитектуры в LLVM

Техника "совмещения и сопоставления" позволяет разработчикам генераторов кода для архитектур выбирать необходимые для генерации кода действия и делает возможным повторное использование большого количества кода для разных архитектур. Из-за этого возникает еще одна сложность: каждый разделяемый между архитектурами компонент должен иметь возможность подстраиваться под свойства используемой архитектуры стандартным образом. Например, используемая в нескольких архитектурах система резервирования регистров должна иметь доступ к файлу описания регистров для каждой архитектуры и обладать информацией об условиях использования инструкций и их операндов совестно с регистрами. В LLVM используется решение, при котором для каждой целевой архитектуры создается описание на декларативном предметно-ориентированном языке программирования (набор файлов с расширением .td), обрабатываемое с помощью инструмента tblgen. Процесс генерации кода (упрощенный) для архитектуры x86 показан на Рисунке 11.5.

Упрощенный процесс генерации кода для целевой архитектуры x86
Рисунок 11.5: Упрощенный процесс генерации кода для целевой архитектуры x86

Различные подсистемы, поддерживаемые файлами с расширением .td позволяют разработчикам генераторов кода для разных архитектур последовательно обозначить особенности работы их архитектур. Например, система генерации кода для архитектуры x86 задает класс регистров, включающий в себя все 32-битные регистры и имеющий название "GR32" (в файлах с расширением .td специфичные для архитектуры объявления записываются заглавными буквами) следующим образом:

def GR32 : RegisterClass<[i32], 32,
  [EAX, ECX, EDX, ESI, EDI, EBX, EBP, ESP,
   R8D, R9D, R10D, R11D, R14D, R15D, R12D, R13D]> { ... }

Это объявление сообщает о том, что регистры этого класса могут хранить 32-битные целочисленные значения ("i32"), предпочтительно их выравнивание по границам 32 бит, всего класс содержит 16 регистров (которые объявлены в одном из файлов с расширением .td), а также приводятся дополнительные данные для установления порядка резервирования регистров и других параметров. После добавления этого объявления в файл, специфические инструкции могут ссылаться на него, используя его в качестве операнда. Например, инструкция для работы с 32-битными регистрами описывается следующим образом:

let Constraints = "$src = $dst" in
def NOT32r : I<0xF7, MRM2r,
               (outs GR32:$dst), (ins GR32:$src),
               "not{l}\t$dst",
               [(set GR32:$dst, (not GR32:$src))]>;

Эта описание сообщает о том, что NOT32r является инструкцией (используется класс I tblgen), задается информация о кодировании (0xF7, MRM2r), задается объявленный для "вывода" 32-битный регистр с именем $dst и объявленный для "ввода" 32-битный регистр с именем $src (описанный выше класс регистров GR32 устанавливает, какие регистры могут использоваться для операндов), задается синтаксис ассемблера для инструкции (используется объявление () для поддержки и синтаксиса AT&T и синтаксиса Intel), задается результат выполнения инструкции и шаблон, с которым должно совпадать объявление, в последней строке. Условие "let" в первой строке сообщает системе резервирования регистров о том, что для ввода и вывода данных должен быть зарезервирован один и тот же физический регистр.

Это объявление является очень точным описанием инструкции, поэтому стандартный код LLVM может быть сформирован с учетом полученной из него информации (с помощью инструмента tblgen). Одного этого объявления достаточно для системы выбора инструкций чтобы сформировать эту инструкцию на основе проверки совпадения с шаблоном данных из из IR-представления, передаваемых компилятору. Данное объявление также сообщает системе резервирования регистров о том, как работать с данной инструкцией, чего вполне достаточно для кодирования и декодирования инструкции в формат машинного кода, а также достаточно для поиска и вывода инструкции в текстовой форме. Эти возможности позволяют использовать систему генерации кода для архитектуры x86 в качестве отдельного ассемблера x86 (который может быть заменой ассемблера "gas" от GNU) и дизассемблеров на основе описания целевой архитектуры, а также кодировать инструкцию для использования JIT.

В дополнение к полезным функциям, наличие нескольких экземпляров данных, полученных из одного и того же источника полезно и для других целей. Данный подход делает практически невозможным рассогласование между ассемблером и дизассемблером в плане синтаксиса ассемблера и бинарного кода. Также это позволяет проводить простое тестирование: кодирование инструкций может быть проверено с помощью unit-тестирования без использования всего генератора кода.

Хотя в файлах с расширением .td и должно быть собрано столько полезной информации в удобной декларативной форме, сколько возможно, ее не достаточно. Напротив, от разработчиков систем генерации кода для разных архитектур требуется разработка кода на языке C++ для реализации различных функций и специфических для данной архитектуры фаз генерации исходного кода, которые могут понадобиться (таких, как X86FloatPoint.cpp для работы со стеком чисел с плавающей точкой). Так как в составе LLVM появляется поддержка новых архитектур, все более и более важным становится повышение количества архитектур, которые могут быть описаны с помощью файлов с расширением .td, поэтому и проводится работа по увеличению количества данных в этих файлах. Большим достоинством этого подхода является тот факт, что разрабатывать системы генерации кода для различных архитектур с использованием LLVM со временем становится все проще.

11.6. Интересные возможности, предоставляемые модульной архитектурой

Кроме того, что модульная архитектура является достаточно элегантным решением, она предоставляет клиентам библиотек LLVM некоторые интересные возможности. Эти возможности реализуются благодаря тому факту, что LLVM предоставляет функции, позволяя разработчику клиента выбирать большинство политик их использования.

11.6.1. Выбор момента и порядка выполнения каждой из фаз

Как упоминалось ранее, представление LLVM IR может быть эффективно преобразовано в бинарный формат, известный как биткод LLVM, а также из него. Так как представление LLVM IR является самодостаточным и процесс преобразования происходит без потерь, мы можем выполнить часть компиляции, сохранить данные на диск и после этого продолжить работу в будущем. Эта особенность позволяет реализовать ряд интересных возможностей, включающий в себя поддержку оптимизации времени связывании и времени установки, которые позволяют осуществлять генерацию кода после процесса компиляции.

Оптимизация времени связывания (Link-Time Optimization) решает проблему традиционной обработки компилятором одной единицы трансляции в каждый момент времени (т.е. файла исходного кода .c со всеми заголовками) и невозможности проведения оптимизаций (таких, как обработка inline-функций) в рамках нескольких файлов одновременно. Компиляторы на основе LLVM, такие, как Clang, поддерживают эту возможность с помощью аргументов командной строки -flto или -O4. Эти аргументы сообщают компилятору о том, что нужно сформировать биткод LLVM и записать его в файл с расширением .o вместо записи объектного файла для данной архитектуры и задерживают генерацию кода до момента связывания, как показано на Рисунке 11.6.

Оптимизация времени связывания
Рисунок 11.6: Оптимизация времени связывания

Подробное описание процесса данной оптимизации зависит от используемой операционной системы, но важным моментом является то, что компоновщик определяет, находится ли в файлах с расширением .o биткод LLVM или эти файлы являются объектными файлами для данной архитектуры. Когда установлено наличие биткода, компоновщик считывает содержимое файлов в память, производит связывание, после чего использует по отношению к коду оптимизатор LLVM. Так как теперь оптимизатор может обрабатывать гораздо больший объем кода, у него есть возможность преобразовывать inline-функии, устанавливать константы, проводить более агрессивное удаление фрагментов неиспользуемого кода и проводить другие оптимизации в рамках множества файлов. Хотя многие современные компиляторы поддерживают оптимизации времени связывания, большинство из них (т.е. GCC, Open64, компилятор Intel, и.т.д.) выполняют эти оптимизации с использованием ресурсоемкого и медленного процесса преобразования кода. В LLVM оптимизация времени связывания является естественным методом использования архитектуры системы и работает с различными исходными языками программирования (в отличие от множества других компиляторов), так как представление LLVM IR на самом деле не зависит от исходного языка программирования.

Оптимизация времени установки основана на идее задержки процесса генерации кода на период до установки, превышающий по длительности период до связывания, как показано на Рисунке 11.7. Момент установки особенно интересен (в случаях, когда распространяются коробочные копии программного обеспечения, происходит его скачивание или установка на мобильное устройство, и.т.д.), так как в этот момент становятся известны особенности устройства, на котором программное обеспечение будет функционировать. В семействе устройств с архитектурой x86, например, существует множество чипов с различными характеристиками. Задерживая выбор инструкций, планирование и другие аспекты генерации кода, вы можете подстроиться под специфическое аппаратное обеспечение, на котором будет работать ваше приложение.

Оптимизация времени установки
Рисунок 11.7: Оптимизация времени установки

11.6.2. Тестирование элементов оптимизатора

Компиляторы являются очень сложными программными продуктами, для которых важно качество, поэтому проведение тестирования просто необходимо. Например, после исправления ошибки, приводящей к краху оптимизатора, должно быть произведено тестирование на предмет регрессий для уверенности в том, что ошибка не проявляется снова. Традиционным способом тестирования является разработка программы на языке C и передача компилятору файла исходного кода с расширением .c для проверки корректности его работы и отсутствия аварийного завершения процесса компиляции. Этот подход, например, используется в наборе тестов компилятора GCC.

Недостатком этого подхода является то, что компилятор состоит из множества различных подсистем и даже различных фаз оптимизатора, каждая из которых имеет возможность изменить исходный код после некорректной работы предыдущей подсистемы. Если что-либо изменяется в системе предварительной обработки кода или на ранних стадиях работы оптимизатора, проверка с помощью данного подхода может оказаться невозможной.

Используя текстовую форму представления LLVM IR совместно с модульной архитектурой оптимизатора, набор тестов LLVM позволяет проводить тестирование на наличие регрессий отдельных компонентов, загружая представление LLVM IR с диска, подвергая его обработке с помощью одной фазы оптимизации и проверяя результат. Ниже приведен простой вариант теста, с помощью которого проводится проверка корректности работы алгоритма фазы оптимизации использования констант для инструкции сложения.

; RUN: opt < %s -constprop -S | FileCheck %s
define i32 @test() {
  %A = add i32 4, 5
  ret i32 %A
  ; CHECK: @test()
  ; CHECK: ret i32 9
}

Строка с директивой RUN задает команду для выполнения: в данном случае используются инструменты с интерфейсом командной строки opt и FileCheck. Программа opt является всего лишь оберткой над менеджером фаз оптимизации LLVM, которая связана со всеми стандартными фазами (и может динамически подгружать дополнения с другими алгоритмами фаз) и позволяет использовать их с помощью интерфейса командной строки. Инструмент FileCheck проверяет, соответствуют ли данные, поступившие на стандартный ввод, серии описаний с использованием директив CHECK. В данном случае простейший тест проверяет корректность оптимизации операции сложения констант 4 и 5 с помощью инструкции add и получения в итоге значения 9 с помощью фазы оптимизации constprop.

Хотя этот пример и может выглядеть достаточно тривиальным, подобное тестирование сложно провести при помощи файлов исходного кода с расширением .c: обычно системы предварительной обработки кода производят действия с константами исходного кода во время его разбора, поэтому разработка кода, достигающего оптимизатора в первозданном виде, достаточно сложна и трудоемка. Так как мы можем загружать представление LLVM IR в виде текста и отправлять его для обработки с помощью интересующего алгоритма фазы оптимизации, после чего направлять вывод в другой текстовый файл, тестирование на наличие регрессий и корректность работы функций интересующих нас компонентов может осуществляться достаточно очевидно.

11.6.3. Автоматическое тестирование с помощью BugPoint

В случае обнаружения ошибки в компиляторе или в другом клиенте библиотек LLVM, первым шагом для ее устранения является создание условий для ее воспроизведения. Как только ошибка начинает воспроизводиться, следует минимизировать размер примера для ее воспроизведения и определить ту часть LLVM, в которой она проявляется, такую, как алгоритм фазы оптимизации. Хотя вы в конечном счете и научитесь это делать, данный процесс является скучным, неавтоматизированным и особенно сложным в случаях, когда компилятор генерирует некорректный код, но не завершается аварийно.

Инструмент BugPoint из состава LLVM7 использует преобразование в представление LLVM IR и модульную архитектуру для автоматизации этого процесса. Например, при передаче исходного файла с расширением .ll или .bc вместе со списком фаз оптимизации, при использовании которых происходит крах оптимизатора, BugPoint сокращает объем кода до небольшого тестового примера и определяет, при выполнении какой из фаз оптимизации происходит крах. После этого он выводит сокращенный пример кода для тестирования и параметры командной строки для программы opt, позволяющие воспроизвести ошибку. Формирование результата путем сокращения объема кода и количества фаз оптимизации происходит с помощью техник, аналогичных отладке при помощи изменений ("delta debugging"). Так как BugPoint работает со структурой представления LLVM IR, не происходит траты времени впустую на генерацию некорректного представления и передачу его оптимизатору, как в случае использования стандартного инструмента "delta" с интерфейсом командной строки.

В более сложном случае при некорректной компиляции вы можете задать файл с кодом, информацию о генераторе кода, команды для передачи исполняемому файлу и образец вывода. BugPoint сначала определит, является ли источником проблемы оптимизатор или генератор кода, после чего будет постоянно разделять тестирование на две части: код будет передаваться "корректно работающему" компоненту и "некорректно работающему" компоненту. В ходе отправки все большего и большего объема кода некорректно работающему генератору кода, объем кода для воспроизведения неполадки сокращается.

BugPoint является очень простым инструментом, который сохранил беесчетное время, необходимое для тестирования, в течение всего периода существования LLVM. Ни один другой компилятор с открытым исходным кодом не имеет аналогичного инструмента, так как при его работе используется четко описанное промежуточное представление кода. Следует упомянуть о том, что BugPoint не является идеальным инструментом и мог бы быть значительно улучшен в случае полной переработки кода. Он был разработан в 2002 году и с того момента дорабатывался только тогда, когда кто-либо сталкивался с действительно сложной ошибкой, которую было невозможно отследить с помощью существующего инструмента. Тем не менее, со временем происходило расширение функций данного инструмента (таких, как отладка JIT-компилятора) в условиях отсутствия последовательной архитектуры и постоянного разработчика.

Сноски

  1. http://llvm.org/docs/Bugpoint.html

11.7. Взгляд в прошлое и направления развития в будущем

Модульная архитектура LLVM изначально разрабатывалась не для достижения целей, описанных в данной главе. Она выступала в качестве механизма самозащиты: очевидно, что мы не могли сделать все правильно с первой попытки. Конвейер фаз, например, существует для изоляции фаз с целью их исключения после замены на более удачные реализации8.

Другим важным аспектом (и дискуссионной темой для разработчиков клиентов библиотек) является развитие LLVM и наше желание переосмыслить принятые ранее решения, внеся большие изменения в API без заботы об обратной совместимости. Кардинальные изменения представления LLVM IR, например, требуют изменения алгоритмов всех фаз оптимизации и приводят к значительным изменениям в API С++. Мы выполнили эти действия в нескольких случаях и, хотя это и нарушило работу клиентов, это также было важно для последующей поддержки высокого темпа развития. Для упрощения разработки сторонних клиентов (и для создания биндингов для других языков) мы предоставляем обертки на языке C для многих популярных API (которые остаются полностью стабильными), а также планируем, что новые версии LLVM смогут использовать файлы с расширениями .ll и .bc, созданные с помощью устаревших версий.

Смотря в будущее, мы хотели бы продолжить работу над модульной архитектурой LLVM и упрощением использования ее частей. Например, генератор кода все еще монолитен: на данный момент невозможно использовать его отдельные функции вне LLVM. Например, если вам необходим JIT-компилятор, но не требуется встроенного ассемблера, обработки исключений или вывода отладочной информации, у вас должна быть возможность сборки генератора кода без связывания с кодом для поддержки данных возможностей. Мы также постоянно улучшаем качество кода, генерируемого оптимизатором и генератором кода, добавляя дополнительные возможности в промежуточное представление IR для лучшей поддержки новых языков программирования и конструкций целевых архитектур, а также улучшаем поддержку оптимизаций, специфичных для языков программирования высокого уровня, в LLVM.

Проект LLVM продолжает развиваться и улучшается различными путями. Действительно захватывающе наблюдать множество способов использования LLVM в сторонних проектах и появление его в таких неожиданных контекстах, о которых даже не могли подумать проектировщики. Новый отладчик LLDB является хорошим примером этого: он использует системы разбора кода C/C++/Objective-C из проекта Clang для разбора выражений, использует LLVM JIT для преобразования их в код для целевой архитектуры, использует дизассемблеры LLVM, а также использует описания целевых архитектур из состава LLVM для обработки соглашений о вызовах и других вещей. Возможность повторного использования существующего кода позволяет разработчикам отладчиков сфокусироваться на логике работы отладчика вместо повторной реализации еще одной (предельно корректной) системы разбора кода C++.

Несмотря на успех, многое еще предстоит сделать и при этом постоянно существует риск превращения со временем LLVM в медленно развивающийся проект. Хотя не существует волшебного решения данной проблемы, я надеюсь, что продолжающееся исследование новых областей применения, желание переосмысления принятых ранее решений и изменение архитектуры с удалением устаревшего кода помогут в этом. Наконец, я хотел бы подчеркнуть, что целью разработки является не создание превосходного программного продукта, а его постоянное улучшение.

Сноски

  1. Я всегда говорю, что подсистема в LLVM не является действительно качественно реализованной до того момента, пока не произведена ее повторная разработка хотя бы раз.

12.1. Краткая история контроля версий

Несмотря на то, что данный текст главным образом посвящен архитектуре Mercurial, многие описываемые в нем концепции схожи с другими системами контроля версий. Для плодотворного рассказа о Mercurial я бы хотел описать некоторые такие концепции, применяемые в других системах. Для создания общей перспективы я также приведу краткую историю этих программ.

Системы контроля версий были изобретены для помощи разработчикам в совместной работе над проектами без необходимости ручного отслеживания изменений в файлах и передачи полных копий проекта. В данном тексте я буду вести речь не об исходном коде проекта, а вообще произвольном файловом дереве. Одной из главных функций программ контроля версий является передача изменений в такое дерево. Обычный цикл работы при этом выглядит следующим образом:

  1. Получение самой последней версии cтруктуры файлов от кого-то.
  2. Работа над этой версией структуры, создание набора измененных файлов.
  3. Публикация этих изменений, чтобы другие могли их использовать.

Первое действие, получение файловой структуры, называется checkout. Хранилище, из которого мы получаем и куда отправляем наши изменения, называется репозиторий, а результат checkout’а называется /i>рабочей директорией или рабочей копией. Обновление рабочей копии последними версиями файлов из репозитория называется просто апдейт; иногда при этом требуется слияние (merge), то есть совмещение изменений от разных пользователей в одном файле. Команда diff позволяет просмотреть различия между двумя версиями структуры или файла, обычно при этом проверяются локальные (неопубликованные) изменения в вашей рабочей копии. Изменения публикуются при помощи комманды commit, которая сохраняет изменения из рабочей директории в репозиторий.

12.1.1. Централизованный контроль версий

Первой системой контроля версий была Source Code Control System, SCCS, появившаяся в 1975 году. Она главным образом выполняла сохранение изменений между файлами кода в отдельные файлы, что было более эффективным, чем просто хранение копий, но не помогала в отправке этих изменений остальным участникам рабочего процесса.

В 1982 году ей на смену пришла Revision Control System, RCS, которая была более развитой и бесплатной альтернативой SCCS (и которая до сих пор поддерживается проектом GNU).

После RCS появилась CVS, Concurrent Versioning System, впервые вышедшая в 1986 году как набор скриптов для работы с файлами версий RCS в группах. Большим нововведением в CVS стало то, что в CVS несколько пользователей могут одновременно редактировать файл, а слияние изменений будет выполнено позже (одновременная правка). Появление такой возможности требовало обработки конфликтов редактирования. Разработчики могут отправлять в репозиторий новую версию какого-либо файла только, если она основана на последней доступной в репозитории версии этого файла. Если в репозитории и в моей рабочей копии есть различия, я должен устранить все конфликты между файлами в них (т.е. от правок, затрагивающих те же самые строки).

CVS также привнесла идею веток (branches), которые позволяют разработчикам работать параллельно над различными частями кода, и тэгов, которые дают возможность согласованного обозначения версий, что позволяет легко на нее ссылаться. Хотя изначально изменения в CVS передавались через репозиторий, расположенный на файловой системе с общим доступом, в какой-то момент в CVS была реализована клиент-серверная архитектура для использования в больших сетях (таких как Интернет).

В 2000 году три разработчика собрались вместе, чтобы написать новую систему контроля версий, лишенную некоторых существенных недостатков CVS. Ее окрестили Subversion. Одним из главных отличий новой системы стало то, что Subversion работает с целыми деревьями за один раз, это означает, что изменения в версиях должны быть атомарными, последовательными, изолированными и долговременными. Рабочие копии Subversion также сохраняют первоначальные копии полученных из репозитория изменений, поэтому обычная операция diff (сравнение локального дерева с исходным) выполняется очень быстро.

Одной из интересных концепций в Subversion стало то, что тэги и ветки являются частью дерева проекта. Проект в Subversion обычно разделен на три части: тэги, ветки и ствол (trunk). Это решение оказалось интуитивно понятным для пользователей, которые не были знакомы с системами контроля версий, хотя гибкость присущая данному дизайну принесла немало проблем для инструментов конвертации, потому что тэги и ветки имеют более структурированное представление в других системах.

Все вышеназванные системы являются централизованными; начиная с CVS, все они знают как обмениваться изменениями между собой, при этом они используют какой-то определенный компьютер, на котором отслеживается история изменений репозитория. Распределенная система контроля версий вместо этого хранит копию всей (или большей части) истории из репозитория на каждом компьютере, у которого есть его рабочая копия.

12.1.2. Распределенный контроль версий

Несмотря на то, что Subversion явно превосходила CVS, у нее все равно был ряд недостатков.

Прежде всего во всех централизованных системах контроля версий фиксирование изменений (операция commit) и их публикация по сути являются одним и тем же, так как история репозитория хранится централизованно в одном месте. Это означает, что отправка изменений без доступа к сети невозможна.

Во-вторых, доступ к репозиториям в централизованных системах всегда требует передачи данных по сети на сервер, что делает такие системы относительно медленными по сравнению с локальным доступом в распределенных системах.

В-третьих, описанные ранее системы имели определенные недостатки при отслеживании слияний (хотя в некоторых из них с тех пор их поддержка улучшилась). Для больших групп разработчиков, работающих одновременно, важно, чтобы система контроля версий записывала, какие изменения были включены в новые версии кода, чтобы ничего не потерялось и последующие слияния могли использовать эту информацию.

В-четвертых, централизация, которая требуется традиционным системами контроля версий, кажется искусственной и выдвигает требование наличие единого пространства для интеграции. Сторонники распределенных системых контроля версий утверждают, что распределенные системы позволяют более органично организовать работу: разработчики могут передавать и интегрировать изменения так, как того требует проект в каждый момент времени.

Для решения описанных выше проблем появилось несколько инструментов. С моей позиции (позиции open-source разработчика) самыми главными на 2011 год стали Git, Mercurial и Bazaar. Проекты Git и Mercurial были начаты в 2005 году, когда разработчики ядра Linux решили больше не использовать проприетарную систему BitKeeper. Обе были начаты разработчиками Linux (Линусом Торвальдсом и Мэттом Маколлом, соответственно) с целью создания систем контроля версий, которые могли бы работать с сотнями тысяч изменений в десятках тысяч файлов (например, с ядром Linux). И на Мэтта, и на Линуса оказала влияние Monotone VCS. Bazaar разрабатывалась отдельно, но стала широко использоваться примерно в это же время, так как была использована компанией Canonical для всех своих проектов.

Создание распределенной системы контроля версий, естественно, представляет определенные сложности, многие из которых присущи всем распределенным системам. Для начала, в то время как в централизованных системах на сервере всегда находилась каноническая версия истории изменений, в распределенных системах такой версии нет. Изменения могут фиксироваться параллельно, что делает невозможным сортировку изменений по времени в каждом отдельно взятом репозитории.

Практически повсеместно для решения этой проблемы используется направленный ациклический граф изменений (DAG) вместо линейного упорядочения (Рисунок 1). То есть добавленное и закоммиченное изменение кода является потомком той версии кода, на основе которой оно было сделано, и никакая версия не может зависеть от самой себя или своих потомков. В этой схеме у нас есть три специальных типа версий кода: корневая версия, которая не имеет предков (репозиторий может иметь несколько корней), объединяющая ревизия (у которой несколько предков) и главная версия, у которой нет потомков. Каждое хранилище начинается с пустой корневой версии кода и далее продолжается от нее по нескольким линиям изменений, заканчиваясь одной или несколькими главными версиями. Если два пользователя независимо друг от друга закоммитили свои изменения, и один их них хочет получить изменения кода от другого, то ему придется явно объединить изменения, внесенные другим пользователем, в новую версию, который он затем коммитит как объединяющую версию.

Рис.12.1: Направленный ациклический граф версий

Обратите внимание, что модель данного графа помогает решить некоторые проблемы, которые вызывают затруднения в централизованных системах: объединяющие версии используются для записи информации о вновь объединенных ветках графа. Получающийся в итоге граф может также полезно изображать большую группу параллельных веток, соединенных в меньшие группы, которые в конце концов будут соединены в одну специальную ветку, считающуюся канонической.

Этот подход требует, чтобы система отслеживала, что является предком каждого изменения в коде. Для облегчения обмена данными между изменениями, каждое из них обычно хранит информацию о своих предках, поэтому каждое изменение обычно имеет идентификатор. Хотя некоторые системы используют UUID или подобные схемы, Git и Mercurial предпочли SHA1-хэши содержимого. При этом дополнительным полезным свойством становится то, что ID может использоваться для верификации самого набора изменений. В действительности, так как информация о предках включается в захэшированные данные, вся история, идущая вплоть до любой версии, может быть проконтролирована при помощи ее хэша. Имена авторов, сообщения при коммите изменений, временные метки и другие метаданные хэшируются также, как и само содержимое файлов новой версии, поэтому они также могут быть верифицированы. А так как временные метки записываются во время фиксации, они также не обязательно идут друг за другом в каждом конкретном репозитории.

Все это может быть довольно сложным для людей, которые прежде пользовались только централизованными VCS: нет одного целого числа для обозначения версии, только 40-символьная шестнадцатиричная строка. Кроме того, больше нет глобальной сортировки, только локальная; вместо глобальной линейной сортировки есть только направленный упорядоченный граф. Случайное создание нового направления разработки при отправке изменения в родительскую версию, у которой уже есть одно направление-потомок, может приводить в недоумение, если вы привыкли получать предупреждение от системы контроля версий, когда подобная ситуация происходила раньше.

К счастью, существуют инструменты для помощи в визуализации дерева, и Mercurial предоставляет возможность использования коротких версий хэша, а также чисел для идентификации локальных изменений. В последнем случае используется увеличивающееся целое число, которое отображает порядок, в котором изменения были добавлены в копию. Так как данный порядок может быть различным от копии к копии, его нельзя использовать в нелокальных операциях.

12.2. Структуры данных

Теперь, когда концепция направленного ациклического графа должна быть достаточно ясной, давайте рассмотрим как эти графы хранятся в Mercurial. Модель графа является центральной во внутреннем механизме Mercurial, в действительности мы используем несколько различных графов в хранилище репозитория на диске (а также в структуре кода в памяти). В этой секции объясняется, что это за структуры и как они взаимодействуют вместе.

12.2.1. Проблемы

До того, как мы окунемся в информацию о структурах данных, я бы хотел немного рассказать об окружении, в котором развивался Mercurial. Первое упоминание о Mercurial может быть найдено в сообщении, которое Мэтт Маколл отослал в список рассылки ядра Linux в апреле 2005 года. Это произошло вскоре после того, как было решено, что BitKeeper больше не может быть использован для развития ядра. Мэтт начал свое сообщение, обозначив некоторые цели: система должна быть простой, масштабируемой и эффективной.

В своем докладе «К лучшему управлению источниками данных: Revlog и Mercurial» в 2006 году Мэтт заявил, что современная система контроля версий должна работать с деревьями, состоящими из миллиона файлов, работать с миллионами изменений и масштабироваться между тысячами пользователей, создающими новые версии кода паралелльно в течение десятилетий. Исходя из этих целей он указал лимитирующие технологические факторы

Скорость чтения диска и пропускная способность WAN являются сегодня ограничивающими факторами, и поэтому система должна быть оптимизирована с их учетом. В докладе также излагаются обычные сценарии или критерии для оценки производительности таких систем на уровне файлов:

В докладе также рассматриваются подобные сценарии на уровне проекта. Основными операциями на этот уровне являются получение версии, коммит изменений (создание новой версии), поиск изменений в рабочей копии. Последняя операция, в частности, может быть медленной для больших деревьев (для проектов типа Mozilla или NetBeans, каждый из которых использует Mercurial для контроля версий).

12.2.2. Быстрое хранение версий: Revlogs

Решение, которое нашел Мэтт для Mercurial, было названо revlog (сокрещенно от revision log – лог версий). Revlog — это способ эффективного хранения версий файлов (каждая из версий при этом содержит определенные изменения по сравнению с предыдущей). Такой способ должен быть эффективным с точки зрения времени доступа (то есть оптимизированным для поиска по диску) и занимаемого дискового пространства, учитывая обычные сценарии, описанные в предыдущей секции. Чтобы удовлетворить этим критериям, revlog представляет собой два файла на диске: индекс и файл данных.

Таблица 1: Формат записи Mercurial

6 байт

Смещение фрагмента

2 байта

Флаги

4 байта

Длина фрагмента

4 байта

Длина в несжатом виде

4 байта

Базовая версия

4 байта

Связанная версия

4 байта

Версия-предок 1

4 байта

Версия-предок 2

32 байта

Хэш

Индекс состоит из записей фиксированной длины, содержимое которых описано в таблице 1. То, что записи имеют фиксированную длину, позволяет прямое (т.е. за постоянное время) обращение к версии по ее локальному номеру: мы можем просто прочитать файл индекса до определенной позиции (которая вычисляется как «длина записи в индексе умноженная на номер версии») для нахождения нужных данных. Разделение индекса и данных также означает, что мы можем быстро прочитать данные индекса без необходимости поиска по диску по всему файлу данных.

Смещение фрагмента и длина фрагмента указывают на фрагмент файла данных, который нужно прочитать, чтобы получить сжатые данные для данной версии. Интересно то, как определяется, когда нужно сохранить новую базовую версию. Это решение основывается на сравнении совокупного размера изменений и размера несжатой версии (данные сжимаются при помощи zlib, чтобы занимать на диске еще меньше места). Ограничивая таким образом размер цепочки изменений, мы точно знаем, что восстановление данных для конкретной версии не потребует чтения слишком большого количества информации об изменениях.

Поле «связанные версии» используется для того, чтобы зависящий revlog указывал на revlog более высокого уровня (подробнее об этом будет рассказано далее), родительские версии хранятся с указанием локального целочисленного номера версии. Опять же это делает простым поиск соответствующих данных через revlog. Хэш используется для хранения уникального идентификатора конкретного внесенного изменения. Мы используем 32 байта вместо 20 байт, требуемых для SHA1, для поддержки возможных изменений в будущем.

12.2.3. Три revlog

Основная структура данных истории хранится в revlog, на ее основе мы можем создать модель данных для нашей файловой структуры. Модель состоит из трех типов revlog: лог изменений, манифесты и файловый лог. Лог изменений содержит метаданные для каждой версии с указателем на манифест (то есть с идентификатором узла одной версии в манифесте). В свою очередь манифест представляет собой список имен файлов, каждому из которых сопоставлен идентификатор узла, указывающий на версию в файловом логе. В коде у нас созданы классы для лога изменений, манифеста и файлового лога, каждый из которых является потомком общего класса revlog, что понятно отражает обе концепции.

Рис.12.2: Структура лога

Версия лога изменений выглядит следующим образом:

0a773e3480fe58d62dcc67bd9f7380d6403e26fa
Dirkjan Ochtman 
1276097267 -7200
mercurial/discovery.py
discovery: fix description line

Эти данные вы получаете из слоя revlog; слой лога изменений превращает его в простой список значений. Первая строка содержит хэш манифеста, затем идет имя автора, дата и время (в форме метки времени Unix и смещения временной зоны), список затронутых файлов и сообщение с описанием. Здесь скрыта одна вещь: мы разрешаем добавление произвольных метаданных в лог изменений, и для того, чтобы сохранить обратную совместимость, эти данные идут после метки времени. Затем идет манифест:

.hgignore\x006d2dc16e96ab48b2fcca44f7e9f4b8c3289cb701
.hgsigs\x00de81f258b33189c609d299fd605e6c72182d7359
.hgtags\x00b174a4a4813ddd89c1d2f88878e05acc58263efa
CONTRIBUTORS\x007c8afb9501740a450c549b4b1f002c803c45193a
COPYING\x005ac863e17c7035f1d11828d848fb2ca450d89794
…

Это версия манифеста, на которую указывает изменение 0a773e (интерфейс пользователя Mercurial позволяет сокращать идентификатор до любого префикса, по которому можно однозначно идентифицировать данные). Это простой список всех файлов в дереве, один файл в каждой строке, где после имени файла идет нулевой байт, затем шестнадцатиричный идентификатор узла, который указывает на файловый лог данного файла. Директории в дереве представлены не отдельно, а просто подразумеваются за счет слэшей в путях. Помните, что изменения в манифесте хранятся в хранилище аналогично любому другому revlog, поэтому данная структура дает возможность легко слою revlog хранить только измененные файлы и их новые хэши для любой новой версии. Манифест обычно представлен структурой данных типа «таблица хэшированных значений» в коде Python, при этом имена файлов являются ключами, а узлы — значениями.

Третий тип revlog — это файловый лог. Файловые логи хранятся во внутренней папке Mercurial под названием store, при этом они называются практически также, как и файлы, которые они отслеживают. Имена немного кодируются, чтобы сохранить кроссплатформенность между основными операционными системами. Например, нам приходится иметь дело с зависимостью от регистра символов в названии файлов и папок на Windows и Mac OS X, неразрешенными именами файлов в Windows и различными кодировками, используемыми в разных файловых системах. Как вы можете себе представить, сделать надежной кроссплатформенную работу довольно сложно. С другой стороны содержимое версии файлового лога не настолько интересно: просто содержимое файла плюс некоторые дополнительные префиксы метаданных (которые мы используем для отслеживания копий файлов и файлов с тем же именем, помимо прочего).

Эта модель дает нам полный доступ к хранилищу данных в репозитории Mercurial, но она не всегда является очень удобной. В то время как реальная используемая модель ориентирована вертикально (один файловый лог на файл), разработчики Mercurial часто понимают, что им хотелось бы работать со всеми деталями из одной версии кода: они начинают с изменений из лога изменений и им нужен простой доступ к манифесту и файловому логу из той же версии. Позже был добавлен новый набор классов, по иерархии располагающийся поверх revlog. Так появились контексты.

Одним из преимуществ существующего способа создания отдельных revlog является очередность их создания. Сначала добавляется информация в файловый лог, затем в манифест, наконец, в лог изменений, поэтому данные в репозитории всегда в целостном состоянии. Любой процесс, начинающий читать лог изменений, может быть уверен в том, что все указатели на другие revlog валидны, это снимает множество проблем в этой области. Однако, в Mercurial есть несколько явных блокировок для предотвращения параллельного добавления данных в revlog двумя процессами.

12.2.4. Рабочая копия

Последняя важная структура данных называется dirstate. Dirstate по сути является представлением того, что находится в рабочей папке в каждый момент времени. Более того, в ней отслеживаются изменения того, какая версия кода была отправлена: это отправная точка для всех сравнений при помощи команд status или diff, кроме того она определяет родительскую версию (или версии) для следующих изменений, которые будет закоммичены. В dirstate будет создан набор из двух родителей каждый раз, когда будет применена команда merge с целью объединить одни изменения с другими.

Так как команды status и diff являются очень распространенными (они помогают отслеживать прогресс в том, что у вас есть сейчас по сравнению с предыдущими изменениями), dirstate также содержит кэш состояния рабочей копии с последнего раза, когда она была прочитана Mercurial. Отслеживание времени последней модификации и размеров файлов позволяет ускорить обход структуры файлов. Нам также неоходимо отслеживать состояние файла: был ли он добавлен, удален или объединен в рабочей директории. Это также позволит ускорить обход рабочей копии и упростит получение данной информации в момент коммита изменения.

12.3. Механизм контроля версий

Теперь, когда вы знакомы с лежащими в основе моделями данных и структурой кода на низком уровне, давайте перейдем на более высокий уровень и рассмотрим, как в Mercurial реализованы концепции контроля версий.

12.3.1. Ветки

Ветки используются повсеместно для разделения различных направлений разработки, которые позже будут интегрированы. Это может быть необходимо, если кто-то экспериментирует с новым подходом, чтобы у нас всегда оставалась главное направление разработки в доступном виде (“ветки для новых функций”), или для того, чтобы быстро выпускать релизы с исправлениями для старых версий (“поддерживающие ветки”). Оба подхода часто используются и поддерживаются всеми современными системами контроля версий. В то время как неявные ветки являются распространенными в системах на основе направленного ациклического графа, именованные ветки (такие, где имя ветки хранится в метаданных изменений) не столь распространены.

Изначально, в Mercurial не было способа явно указывать имена веток. Вместо этого ветки создавались путем создания новых копий и их раздельной публикации. Такой путь эффективен и легок для понимания, он особенно полезен для веток нового функционала, потому что накладные расходы на эти операции не велики. Однако в больших проектах создание копий может быть довольно затратным: так как хранилище репозитория привязано к файловой системе, создание отдельной рабочей копии является медленным процессом и может потребовать большое дисковое пространство.

Из-за этих недостатков, в Mercurial был добавлен второй способ создания ветвей: добавление имени ветки в метеданные изменения. Появилась команда branch, с ее помощью вы можете задать имя ветки для текущей рабочей директории, тогда это имя будет использовано для коммита следующего изменения. Обычная команда update может быть использована для обновления имени ветки; изменение, закоммиченное в ветке, всегда будет привязано к конкретной ветке. Этот подход называется именованные ветки. Однако, прошло несколько релизов Mercurial прежде чем появилась поддержка закрытия таких веток (закрытие скрывает ее из списка веток). Закрытие реализовано путем добавления дополнительного поля в метаданные изменения, в котором записывается, что данное изменение закрывает ветку. Если у ветки более, чем одно направление, все они должны быть закрыты перед тем, как ветка исчезнет из списка веток репозитория.

Конечно, существует более чем один способ реализации веток. В Git используется другой способ наименования веток: при помощи ссылок. Ссылки — это имена, указывающие на другой объект в истории Git, обычно на изменение. Это означает, что ветки в Git эфемерные: как только вы убираете ссылку, информация о том, что ветка когда-то существовала, исчезает без следа; это похоже на то, что вы получаете, когда используете отдельную копию Mercurial и объединяете ее обратно с другой точной копией. Это позволяет очень легко и без затрат манипулировать ветками локально, и предотвращает перегруженность списка веток.

Этот способ создания веток оказался очень популярным, гораздо более популярным, чем именованные ветки или клоны веток в Mercurial. Это привело к созданию расширения bookmarksq, которое, вероятно, будет добавлено в Mercurial в будущем. Оно использует простой неверсионный файл для отслеживания ссылок. Сетевой протокол, используемый для обмена данными в Mercurial, был расширен для добавления возможности передачи информации о ссылках.

12.3.2. Тэги

На первый взгляд то, как в Mercurial реализованы тэги, может быть несколько странным. Когда вы в первый раз добавляете тэг (используя команду tag) в репозиторий будет добавлен и закоммичен файл .hgtags. Каждая строка в этом файле содержит идентификатор узла изменения и имя тэга для него. Таким образом, файл тэгов обрабатывается также, как и любой другой файл в репозитории.

На это есть три важные причины. Первая состоит в том, что необходимо иметь возможность изменять тэги; случаются ошибки и нужно, чтобы их можно было исправить или удалить. Вторая, тэги должны быть частью истории изменений: важно видеть, когда был создан тэг, кем и по какой причине, или даже когда тэг был изменен. Третья причина: нужно иметь возможность добавить тэг к изменению, имевшем место в прошлом. Например, некоторые проекты активно тестируют эталон релиза из системы контроля версий до того, как выпустить его.

Все эти свойства легко возможны при наличии данной архитектуры с файлами .hgtags. Хотя некоторые люди не понимают наличия файлов .hgtags в своих рабочих копиях, такие файлы делают интеграцию механизма тэгирования с другими частями Mercurial (например, синхронизацию с другой копией репозитория) очень простой. Если бы тэги существовали вне дерева исходных кодов (как в Git, к примеру), был бы необходим отдельный механизм для контроля источников тэгов и решения конфликтов от дублированных тэгов. Даже несмотря на то, что последняя ситуация не является очень частой, хорошо иметь такую архитектуру, когда подобные проблемы даже не возникают.

Чтобы все это работало правильно, Mercurial только один раз добавляет новые строки в файл .hgtags. Это также облегчает операцию слияния, если тэги были созданы параллельно в разных копиях. Более новый идентификатор узла для каждого конкретного тэга всегда имеет преимущество, а добавление нулевого идентификатора узла (представляющего пустую корневую версию, которая является общей для всех репозиториев) приведет к удалению тэга. Mercurial также принимает во внимание тэги из всех веток в репозитории, используя анализ, какие из них более новые, для определения их порядка.

12.4. Общая структура

Mercurial почти полностью написан на Python, только некоторые части написаны на C (там, где критична производительность всего приложения). Python оказался наиболее подходящим для большей части кода, потому что гораздо легче выражать высокоуровневые концепции на динамическом языке, вроде Python. Так как для большей части кода быстродействие не слишком критично, мы не против того, что нас будут критиковать за то, что мы кое-где упростили себя процесс разрабоки в ущерб производительности.

Модуль в Python находится в одном файле кода. Модули могут содержать столько кода, сколько необходимо, и поэтому являются важным способ организации программы. Модули могут использовать типы или вызывать функции из других модулей путем явного импорта других модулей. Директория, содержая файл __init__.py, является пакетом, и все модули пакета будут доступны интерпретатору Python.

По умолчанию Mercurial устанавливает два пакета в окружение Python: mercurial и hgext. Пакет mercurial содержит ядро, необходимое для запуска Mercurial, в то время как hgext содержит некоторые расширения, которые считаются достаточно полезными и поэтому распространяются вместе с ядром. Однако, если они нужны, их все же нужно вручную включить в конфигурационном файле (это будет описано позже).

Для простоты Mercurial является консольным приложением. Это означает, что у него простой интерфейс: пользователь вызывает скрипт hg и команду. Эта команда (например, log, diff или commit) может принимать несколько опций или аргументов; некоторые опции подходят для всех команд. В том, что касается интерфейса, есть три нюанса:

Рис.12.3: Граф импорта

Начало этого процесса можно удобно наблюдать с помощью графа импорта на рисунке 3. Все аргументы командной строки передаются в функцию в модуль-диспетчер (dispatch). Первое что происходит: создается экземпляр объекта ui. Класс ui попытается найти конфигурационные файлы в нескольких хорошо известных местах (таких, как ваша домашняя папка) и сохранить параметры конфигурации в объекте ui. Конфигурационные файлы могут содержать пути к расширениям, которые также должны быть загружены в этот момент. Любой глобальный параметр, переданный через командую строку, также сохраняется в объекте ui на этом этапе.

После того, как все это будет выполнено, нам нужно решить, нужно ли создавать объект репозитория. В то время, как большинство команд требуют наличия локального репозитория (представлен классом localrepo из модуля localrepo), некоторые команды могут работать с удаленными репозиториями (через HTTP, SSH или каким-либо другим зарегистрированным образом), а некоторые команды могут выполнять свою работу вообще без обращения к репозиторию. Последняя категория включает команду init, которая используется для инициализации нового репозитория.

Все ключевые команды ядра представлены одной функцией в модуле commands; это дает возможность очень легко находить код каждой функции. Модуль команд также содержит хэшированную таблицу, которая хранит соответствие между именем команды, функцией и принимаемыми ей параметрами. То, как это выполнено, дает возможность наличия общего набора параметров у разных опций (например, у многих команд параметры аналогичны команде log). Описания параметров позволяют модулю-диспетчеру проверять конкретную опцию для любой из команд и конвертировать любые значения в тип, ожидаемый функцией команды. Практически каждая функция также получает доступ к объекту ui и объекту repository.

12.5. Расширяемость

Одним из факторов, который делает Mercurial очень мощным, является возможность написания расширений под него. Так как Python является языком, начать писать на котором довольно просто, а API Mercurial по большей части хорошо спроектирован (хотя и не всегда полностью документирован), некоторые люди начали писать на Python в первый раз именно потому, что они захотели написать расширение для Mercurial.

12.5.1. Написание расширений

Расширения включаются путем добавления строки в один из конфигурационных файлов, которые Mercurial читает при загрузке. Есть несколько способов добавить функционал:

Добавление новых команд может быть выполнено просто путем добавления хэшированной таблицы под названием cmdtable в модуль расширения. Он будет подгружен загрузчиком расширений, который добавит новую команду в таблицу команд, используемую при обработке команд. Аналогично, расширения могут определять функции uisetup и reposetup, которые вызываются в коде диспетчера, после того как созданы объекты пользовательского интерфейса и репозитория. Типичным способом является использования функции reposetup для оборачивания репозитория в подкласс репозитория, предоставляемый расширением. Это позволяет расширению модифицировать базовое поведение. Например, одно расширение, которое я написал, подцепляет uisetup и устанавливает свойство конфигурации ui.username в зависимости от данных доступа SSH, доступных из окружения.

Более серьезные расширения могут быть написаны для добавления типов репозиториев. Например, проект hgsubversion (не включенный в Mercurial) регистрирует тип репозитория Subversion. Это делает возможным клонирование репозитория Subversion, как будто это репозиторий Mercurial. Существует даже возможность обратной отправки данных в репозиторий Subversion, хотя в определенных случаях возможны проблемы из-за различий в двух системах. Пользовательский интерфейс, с другой стороны, полностью прозрачен.

Для тех кто хочет глубоко изменить Mercurial, в мире динамических языков существует такая вещь как «monkeypatching». Так как код расширений выполняется в том же адресном пространстве, что им Mercurial, а Python — это достаточно гибкий язык с широкими рефлективными возможностями, возможно (и достаточно легко) модифицировать любой класс или функцию, определенную в Mercurial. Хотя это может привести к довольно уродливым хакам, это действительно мощный механизм. Например, расширение highlight (представлено в hgext) модифицирует встроенный веб-сервер, чтобы добавить подсветку синтаксиса на страницы браузера репозитория, что позволяет вам просматривать содержимое файлов.

Есть еще один способ расширения функциональности Mercurial, гораздо более простой: алиасы. Любой конфигурационный файл может определять алиас в качестве нового имени для существующей команды с определенным набором заданных опций. Это также дает возможность давать более короткие названия любой команде. Последние версии Mercurial также включают возможность вызова команд шелла через алиасы, так что вы можете создавать сложные команды не используя ничего, кроме шелл-скриптов.

12.5.2. Перехватчики

Системы контроля версий давно предоставляли механизм перехватчиков в качестве способа взаимодействия событий этих систем с окружающим миром. Обычной практикой является отправка уведомляющего сообщения в систему постоянной интеграции или обновление рабочей копии на веб-сервере таким образом, чтобы изменения стали видимы во всем мире. Конечно, такие возможности присутствуют и у Mercurial.

В действительности, здесь также присутствуют два варианта. Один больше похож на традиционные перехватчики в других системах контроля версий, он вызывает скрипты в шелле. Другой более интересен, потом что позволяет пользователям вызывать Python-овские перехватчики, указывая модуль Python и имя функции из этого модуля для вызова. Это не только быстрее, потому что работает в том же процессе, но при этом также используются объекты repo и ui, то есть вы можете легко создавать более сложное взаимодействие внутри системы контроля версий.

Перехватчики в Mercurial могут быть разделена на предкомандые, посткомандные, управляющие и дополнительные. Первые две категории просто определяются путем указания ключей pre-команда или post-команда в секции перехватчиков в конфигурационном файле. Для двух других типов есть предопределенный набор событий. Различие в управляющих перехватчиках состоит в том, что они запускаются прямо перед тем, как что-то происходит, и могут не позволить этому событию выполняться дальше. Они обычно используются для валидации изменений каким-либо образом на центральном сервере; по причине распределенной природы Mercurial такие проверки не могут быть выполнены во время коммита изменений. Например, проект Python использует перехватчик, для проверки применения некоторых аспектов стиля кода — если изменение добавляет код, который имеет не разрешенный стиль, он будет отвергнут центральным репозиторием.

Еще один интересный способ использования перехватчиков — это pushlog, который используется в Mozilla и некоторых других компаниях. Pushlog записывает каждое добавление кода (так как при этом может содержаться любое число изменений) и записывает, кто инициировал это добавление и когда, то есть создается своего рода отчетность по использованию репозитория.

12.6. Выводы

Одним из первых решений, которое принял Мэтт, когда начинал разработку Mercurial, было то, что разрабатывать надо на Python. Python зарекомендовал себя с самой лучше стороны с точки зрения расширяемости (через расширения и перехватчики), и на нем очень легко писать код. Он также берет на себя большую часть работы по совместимости кода между различными платформами, что достаточно легко дает возможнсть Mercurial успешно работать с тремя главными операционными системами. С другой стороны Python медленный, по сравнению со многими другими (компилирующимися) языками; в частности запуск интерпретатора достаточно медленный, что не очень хорошо для инструментов, которые запускаются много и часто (таких как система контроля версий), а не работают как долговременные процессы.

Также одним из ранних решений была сложность модификации изменений после их коммита. Так как невозможно изменить версию без модификации ее идентифицирующего хэша, «обратный вызов» изменений после их публикации в Интернете приносит проблемы, и поэтому Mercurial не дает выполнить такую операцию. Однако, изменение незафиксированных версий не должно быть затруднено, и сообщество пыталось упростить этот процесс вскоре после релиза. Существуют расширения, которые пытаются решить эту проблему, но они требуют определенного обучения и не являются интуитивно понятными для пользователей, которые до этого пользователись только основными функциями Mercurial.

Revlog хороши тем, что позволяют уменьшить количество обращений к диску; послойная архитектура лога изменений, манифеста и файлового лога также показала себя с хорошей стороны. Коммит изменений осуществляется быстро и достаточно мало дискового пространства используется для хранения версий. Однако, некоторые операции, например, переименование файлов, выполняются не очень эффективно из-за раздельного хранения версий для каждого файла; в конце концов это будет исправлено, но потребует какого-то нестандартного хака, нарушающего текущую концепцию слоев. Аналогично, пофайловый направленный ациклический граф, используемый для помощи в хранилища файлового лога, не используется на практике достаточно широко, поэтому код, используемый для работы с этими даными может быть посчитан излишним.

Еще одним ключевым фактором для Mercurial была необходимость легкости в обучении. Мы старались собрать большинство требуемых функций в небольшой набор команд со схожими опциями. Идея состояла в том, чтобы Mercurial можно было изучить прогрессивно, особенно тем пользователям, которые использовали другие системы контроля версий до этого; эта философия расширяется до такой степени, что расширения могут быть использованы для модификации Mercurial еще больше в каждом конкретном случае. По этой причине разработчики также пытались создать пользовательский интерфейс, схожий с другими системами контроля версий, Subversion в частности. Также команда попыталась предоставить хорошую документацию, доступную из самого приложения, со ссылками на другие части справки и информацию о командах. Мы следим за тем, чтобы сообщения об ошибках имели смысл, включая подсказки что еще можно сделать вместо той операции, которая завершилась ошибкой.

Некоторые менее важные решения могут быть удивительными для новых пользователей. Например, управление тэгами (как обсуждалось в предыдущей секции) путем хранения их в отдельном файле внутри рабочей директории не понравилось по началу многим пользователям, но данный механизм имеет некоторые весьма ощутимые преимущества (хотя и не лишен недостатков). Кроме того, другие системы контроля версий по умолчанию посылают на сервер изначально загруженные изменения и их предков, в то время как Mercurial посылает каждое зафиксированное изменение, которого нет на удаленном сервере. Оба подхода имеют определенный смысл, и каждый подходит для определенного стиля разработки.

Как и в любом программном проекте, нам пришлось идти на множество компромиссов. Я считаю, что в случае Mercurial мы во многом делали правильный выбор, хотя если оглянуться назад, безусловно, что-то можно было сделать иначе. Исторически, Mercurial оказался частью первого поколения распределенных систем контроля версий, достаточно зрелых для широкого использования. Что касается меня, то я хотел бы увидеть, как будет выглядеть следующее поколение таких систем.

13.6. Заключительное слово

Экосистема NoSQL все еще находится на раннем этапе развития и многие описанные нами системы изменят свои архитектуры, технические решения и интерфейсы. Наиболее важной информацией данной главы является не информация о том, какие действия каждая из систем NoSQL может выполнять в данный момент, а о технических решениях, которые обуславливают комбинацию возможностей, предоставляемых этими системами. Технология NoSQL перекладывает большой объем работы по проектированию на разработчика приложения. Понимание архитектурных компонентов этих систем поможет вам не только создать еще одну замечательную производную систему NoSQL, но также более ответственно использовать существующие версии систем.

13.7. Благодарности

Я признателен Jackie Carter, Mihir Kedia и анонимным рецензентам за их комментарии и пожелания по улучшению этой главы. Написание этой главы также было бы невозможным без многих лет самоотверженного труда сообщества NoSQL. Продолжайте созидательный труд!

Сноски

  1. http://hbase.apache.org/
  2. http://project-voldemort.com/
  3. http://cassandra.apache.org/
  4. http://code.google.com/p/protobuf/
  5. http://thrift.apache.org/
  6. http://avro.apache.org/
  7. http://www.oracle.com/technetwork/database/berkeleydb/overview/index.html
  8. http://redis.io/
  9. http://couchdb.apache.org/
  10. http://www.mongodb.org/
  11. http://www.basho.com/products_riak_overview.php
  12. http://www.hypergraphdb.org/index
  13. http://neo4j.org/
  14. http://memcached.org/
  15. http://hadoop.apache.org/hdfs/
  16. http://github.com/twitter/gizzard
  17. http://hadoop.apache.org/zookeeper/

13.1. Что в имени?

Перед рассмотрением систем NoSQL, давайте сначала разберемся с их названием. Образно говоря, система NoSQL предоставляет пользователю интерфейс запросов, не использующий язык SQL. Сообщество разработчиков систем NoSQL в общем случае рассматривает их более глобально и заявляет, что системы NoSQL являются альтернативой традиционным реляционным базам данных и позволяют разработчикам проектировать приложения, использующие не только интерфейс SQL-запросов. В некоторых случаях вы сможете заменить реляционную базу данных на альтернативную систему NoSQL, а в некоторых вам придется применять метод комбинирования и подбора систем (mix-and-match approach) для решения различных проблем, с которыми вы столкнетесь во время разработки приложения.

Перед погружением в мир систем NoSQL, давайте рассмотрим случаи, в которых язык SQL и реляционная модель будут соответствовать вашим требованиям, а также другие случаи, в которых система NoSQL может оказаться более предпочтительной.

13.1.1. Язык SQL и реляционная модель

SQL является декларативным языком для осуществления запросов данных. В декларативном языке разработчик задает действия, которые должна выполнить система вместо процедурного описания того, как система должна выполнить эти действия. Несколько примеров: поиск записи работника с идентификатором 39, выделение только информации о имени и номере телефона работника из его записи, вывод записей о работниках бухгалтерии, подсчет количества работников в каждом отделе или объединение данных из таблицы с информацией о работниках с данными из таблицы с информацией об управляющих.

В первом приближении язык SQL позволяет вам выполнить эти запросы без понимания того, как данные размещаются на диске, какие индексы используются для доступа к данным или какие алгоритмы используются для обработки данных. Важным архитектурным компонентом большинства реляционных баз данных является оптимизатор запросов, который принимает решение о том, какие из множества логически эквивалентных схем запросов следует выполнить для получения наиболее быстрого ответа на запрос. Эти оптимизаторы обычно работают лучше среднестатистического пользователя базы данных, но иногда они не располагают достаточным количеством информации или используют в значительной степени упрощенную модель системы, что затрудняет генерацию наиболее эффективного запроса.

Реляционные базы данных, наиболее часто используемые на практике, следуют реляционной модели данных. В рамках данной модели различные данные из реального мира хранятся в различных таблицах. Например, все данные работников могут хранится в таблице "Employees", а все данные отделов могут храниться в таблице "Departments". Каждая строка таблицы содержит различные свойства, хранимые в столбцах. Например, работники могут характеризоваться идентификаторами, жалованием, датами рождения, а также именами и фамилиями. Каждое из этих свойств будет храниться в столбце таблицы работников "Employees".

Реляционная модель идет рука об руку с языком запросов SQL. Простые запросы SQL, такие, как фильтры, извлекают все записи, поля которых соответствуют какому-либо выражению (т.е. идентификатор работника = 3 или жалование > $20000). Более сложные запросы заставляют базу данных выполнить дополнительную работу, такую, как объединение данных из нескольких таблиц (т.е. как называется отдел, в котором числится работник с идентификатором 3?). Другие сложные запросы, такие, как запросы вычисления сводных показателей (т.е. какова средняя зарплата моих работников?) могут приводить к полному обходу таблиц.

Реляционная модель данных описывает структурированные объекты с жесткими связями между ними. Запросы к этой модели посредством SQL позволяют осуществлять сложные манипуляции с данными без необходимости разработки сложных алгоритмов. Сложность таких моделей и запросов имеет ограничение, однако:

Отказ от рассмотрения результатов многолетнего проектирования без видимых на то причин не является разумным решением. Когда вы решаете хранить ваши данные в базе данных, рассматривайте возможность использования языка запросов SQL и реляционной модели, которые стали результатом многих лет исследования и разработки и предоставляют богатые возможности моделирования и понятные гарантии в отношении сложных операций. Системы NoSQL являются хорошим решением в том случае, если ваша задача является такой специфичной, как хранение больших объемов данных, работа под большими нагрузками или работа со сложной моделью данных, для чего язык SQL и реляционные базы данных могут быть не в достаточной степени оптимизированы.

13.1.2. Исходные данные для проектирования систем NoSQL

Системы NoSQL в большей степени создавались под влиянием документации от исследовательского сообщества. В то время, как многие документы описывали решения, положенные в основу архитектуры систем NoSQL, две системы следует выделить особо:

Система BigTable компании Google [CDG+06] представляет интересную модель данных, которая упрощает реализацию многоколоночного отсортированного хранилища данных истории. Данные распределяются по множеству серверов с использованием иерархической схемы распределения на основе диапазонов, а данные обновляются в строгой согласованности (подход, который мы обсудим позднее в Разделе 13.5).

Система Dynamo компании Amazon [DHJ+07] использует отличное от предыдущего распределенное хранилище данных с доступом на основе ключей. Модель данных системы Dynamo проще за счет установления соответствия ключей специфичным для приложений бинарным данным. Модель распределения данных более устойчива к ошибкам, но эта цель достигается путем применения подхода, заключающегося в снижении степени согласованности данных и называемого конечной согласованностью (eventual consistency).

Мы подробно рассмотрим каждое из этих решений, но важно понимать, что многие из них могут комбинироваться и подбираться друг для друга. Некоторые системы NoSQL, такие, как HBase1, реализуют архитектуру, аналогичную BigTable. Другая система NoSQL под названием Voldemort2 копирует многие возможности системы Dynamo. Также другие проекты NoSQL, такие, как Cassandra3, переняли ряд возможностей из системы BigTable (ее модель данных), а также ряд других возможностей из системы Dynamo (ее схемы распределения и поддержания согласованности данных).

13.1.3. Характеристики и соображения

Системы NoSQL оставляют в стороне мощный стандарт запросов SQL и являются более простым, но менее обобщенным решением для проектирования систем хранения данных. Эти системы были созданы с надеждой на то, что упрощение механизма обработки данных позволит архитектору лучше предугадать производительность каждого из запросов. В множестве систем NoSQL сложная логика запросов должна быть реализована на стороне приложения, в результате чего производительность запросов к хранилищу данных может быть оценена более точно из-за отсутствия значительных изменений в этих запросах.

Системы NoSQL реализуют больший набор функций, чем поддержка декларативных запросов для манипуляций реляционными данными. Семантики транзакций, постоянство значений и долговечность хранения данных являются гарантиями, требуемыми от систем баз данных такими организациями, как банки. Транзакции гарантируют, что будут записаны либо все данные, либо ничего при комбинировании нескольких потенциально сложных операций в рамках одной, аналогичной списанию денежных средств с одного счета и переводу их на другой счет операции. Постоянство значений заключается в том, что все выполняемые после обновления значения запросы будут возвращать обновленное значение. Долговечность хранения данных гарантирует то, что как только значение подвергается изменению, оно сохраняется в постоянном хранилище (таком, как жесткий диск) и может быть восстановлено в случае краха базы данных.

Системы NoSQL отказываются от некоторых из этих гарантий в обмен на улучшение производительности, что позволяет добиться допустимого и предсказуемого поведения некоторых, не имеющих отношения к банковским системам приложений. Эти ослабления гарантий вместе с изменениями модели данных и языка запросов обычно упрощают безопасное распределение базы данных по множеству машин в случае увеличения объема хранимых данных до пределов возможностей системы хранения данных отдельной машины.

Системы NoSQL находятся в большей степени на раннем этапе своего развития. Архитектурные решения описанных в данной главе систем являются реализациями требований различных пользователей. Наиболее сложной задачей при обобщении архитектурных возможностей некоторых проектов с открытым исходным кодом является то обстоятельство, что каждый проект является движущейся мишенью. Помните о том, что подробности реализации отдельных систем будут меняться. Когда вы будете выбирать систему NoSQL, вы можете использовать эту главу в качестве руководства, но не для выбора системы на основе реализуемых возможностей.

При размышлениях о системах NoSQL следует определиться с приведенными ниже вопросами:

Хотя мы рассмотрим все эти вопросы, последние три одинаково важных вопроса будут обсуждаться в меньшей степени.

13.2. Модели данных и запросов систем NoSQL

Модель данных базы данных задает то, как данные будут логически организованы. Ее модель запросов устанавливает способ получения и обновления данных. Стандартными моделями данных являются реляционная модель, модель хранилища с доступом на основе ключей или различные модели на основе графов. Языки запросов, о которых вы могли слышать, включают SQL, поиск на основе ключей и MapReduce. Системы NoSQL комбинируют различные модели данных и запросов, что в итоге приводит к появлению различных архитектурных решений.

13.2.1. Модели данных систем NoSQL на основе ключей

Системы NoSQL обычно исключают использование реляционной модели и всей выразительности языка SQL, ограничивая поиск в наборах данных одним полем. Например, даже если у записи, соответствующей работнику имеется ряд свойств, у вас есть возможность получить только запись работника с помощью идентификатора этой записи. В результате большинство запросов в системах NoSQL представляют собой поиск на основе ключа. Разработчик выбирает ключ, идентифицирующий каждую из записей и может, в большинстве случаев, извлечь только записи, выполняя поиск их ключей в базе данных.

В системах, основанных на поиске ключей, сложные объединения операций или извлечение данных, соответствующих нескольким ключам, могут потребовать нестандартного подхода к использованию имен ключей. Разработчик, желающий найти запись работника по ее идентификатору и найти всех работников отдела, может создать два типа ключей. Например, ключ employee:30 будет указывать на запись работника с идентификатором 30, а ключ employee_departments:20 может указывать на список всех работников отдела с идентификатором 20. Операция объединения запросов переносится в область логики приложения: для получения записей работников отдела 20 приложение сначала получает список идентификаторов работников с помощью ключа employee_departments:20, после чего в цикле получает записи работников с помощью ключей employee:ID с использованием списка идентификаторов работников.

Модель поиска ключей выгодно отличается благодаря тому, что она предполагает использование постоянных шаблонов запросов - общая нагрузка состоит из нагрузок за счет поиска ключей, которые относительно однообразны и предсказуемы. Профилирование с целью поиска медленно исполняющихся участков кода приложения проще, так как все сложные операции реализованы в коде приложения. С другой стороны, логика работы с моделью данных и бизнес-логика становятся более взаимосвязанными, что затрудняет процесс создания абстракций.

Давайте кратко затронем типы данных, ассоциированных с каждым ключом. Различные системы NoSQL предлагают различные решения в данной области.

Хранилища пар ключ-значение

Простейшей формой хранилища системы NoSQL является хранилище пар ключ-значение. Каждый ключ ставится в соответствие значению, в форме произвольных данных. Хранилище системы NoSQL не располагает информацией об этих данных и просто передает их приложению. В нашем примере базы данных работников Employee ключу employee:30 могут соответствовать данные в формате JSON или таких бинарных форматах, как Protocol Buffers4, Thrift5 или Avro6, хранящие информацию о работнике с идентификатором 30.

Если разработчик использует структурированные форматы для хранения сложных структур данных, соответствующих ключу, он должен обрабатывать данные на уровне приложения: хранилище пар ключ-значение в общем случае не предоставляет механизмов для запросов ключей на основании некоторых свойств соответствующих им значений. Хранилища пар ключ-значение отличаются простотой их модели запросов, обычно состоящей из примитивов для установки, получения и удаления значений (set, get и delete), но не предусматривают возможности добавления простых функций фильтрации на уровне базы данных ввиду непрозрачности этих значений. Система Voldemort, основанная на системе Dynamo компании Amazon, предоставляет распределенное хранилище пар ключ-значение. Система BDB7 предоставляет библиотеку, реализующую интерфейс для работы с парами ключ-значение.

Хранилища пар ключ-структура данных

Хранилища пар ключ-структура данных, ставшие популярными благодаря системе Redis8, ассоциируют каждое значение с определенным типом. В Redis доступные типы, которые могут использоваться значением, включают в себя: целочисленные значения, строки, списки, множества и отсортированные множества. В дополнение к примитивам set / get / delete существуют такие специфичные для типов команды, как увеличение уменьшение целочисленного значения или добавление/извлечение элементов списка, позволяющие реализовать функции запросов модели без значительного влияния на характеристики производительности. Предоставляя простые специфические для типов функции, при этом избегая таких операций как сборки или объединения при работе с множеством ключей, система Redis поддерживает баланс между функциональностью и производительностью.

Хранилища пар ключ-документ

Хранилища пар ключ-документ, такие, как CouchDB9, MongoDB10 и Riak11 ставят в соответствие ключу какой-либо документ, содержащий структурированную информацию. Эти системы хранят документы в JSON или подобном JSON формате. Они содержат списки и словари, которые могут рекурсивно встраиваться друг в друга.

Система MongoDB разделяет пространство ключей на коллекции, поэтому ключи для записей работников (Employees) и записей отделов (Department), например, не будут пересекаться. Системы CouchDB и Riak возлагают задачу отслеживания типов на разработчика. Свобода действий и сложность хранилищ документов представляют обоюдоострый меч: разработчики приложений получают свободу моделирования своих документов, но логика запросов в рамках приложения может стать чрезмерно сложной.

Хранилища семейств столбцов BigTable

Системы HBase и Cassandra основывают свои модели данных на модели, используемой системой BigTable компании Google. В этой модели ключ идентифицирует строку, которая содержит данные, хранящиеся в одном или нескольких семействах столбцов (Column Families - CF). В рамках семейства столбцов каждая строка может содержать множество столбцов. Значения в каждом столбце содержат метку времени, поэтому несколько версий соответствий между строкой и столбцом могу находиться в одном семействе столбцов.

Концептуально можно рассматривать семейства столбцов как хранилища ключей сложной формы (идентификатор строки, семейство столбцов, столбец, метка времени), соответствующих значениям, отсортированным на основе их ключей. В результате работы по проектированию данной системы были выработаны архитектурные решения в области моделей данных, позволившие перенести большую часть функций в пространство ключей. Это особенно полезно при моделировании данных истории с метками времени. Модель изначально поддерживает распределенное размещение столбцов, так как идентификаторы строк, не содержащие необходимых столбцов не должны явно указывать на значения NULL для этих столбцов. С другой стороны, столбцы, содержащие несколько значений NULL или вообще не содержащие их, все же должны хранить идентификатор столбца для каждой строки, что ведет к еще большему потреблению дискового пространства.

Модель данных каждого проекта так или иначе отличается от оригинальной модели системы BigTable, но в рамках системы Cassandra она претерпела наиболее значительные изменения. Система Cassandra вводит понятие суперстолбца в рамках каждого семейства столбцов для реализации нового уровня соответствия, моделирования и индексирования. Она также устраняет понятие локальных групп, которые могли физически хранить объединенное множество семейств столбцов для повышения производительности.

13.2.2. Хранилища на основе графов

Одним из классов хранилищ систем NoSQL являются хранилища на основе графов. Не все генерируемые данные аналогичны по своей структуре и реляционная модель, а также модели на основе ключей не всегда лучшим образом подходят для хранения и осуществления запросов любых данных. Графы являются фундаментальной структурой в компьютерных науках и системы HyperGraphDB12 и Neo4J13 являются двумя популярными системами NoSQL для хранения данных, структурированных в форме графов. Хранилища на основе графов практически по всем параметрам отличаются от других типов хранилищ, рассмотренных нами ранее: они используют отличные модели данных, шаблоны для обхода данных и создания запросов, физическое размещение данных на диске, метод распределения данных по множеству машин, а также семантики запросов транзакций. Мы не будем рассматривать эти фундаментальные отличия из-за ограничений объема главы, но вы должны понимать, что определенные классы данных могут более успешно храниться и модифицироваться с помощью запросов в случае применения хранилищ на основе графов.

13.2.3. Сложные запросы

Существуют известные исключения в отношении методов поиска данных в системах NoSQL только на основе ключей. Система MongoDB позволяет индексировать ваши данные на основе любого количества свойств и использует язык относительно высокого уровня для указания того, какие данные вы хотели бы извлечь. Системы на основе BigTable поддерживают сканеры для итерации в рамках семейства столбцов и выбора определенных элементов с помощью фильтрации по столбцам. Система CouchDB позволяет создавать различные отображения данных и выполнять задачи MapReduce в отношении вашей таблицы для упрощения выполнения более сложных операций поиска и обновления данных. Большинство систем имеют биндинги для системы Hadoop или другого фреймворка MapReduce для выполнения аналитических запросов в отношении наборов данных.

13.2.4. Транзакции

Системы NoSQL обычно отдают приоритет производительности системы над семантиками транзакций. Другие системы на основе SQL позволяют использовать любой набор выражений в рамках транзакции - от простых выборок строк на основе первичного ключа до сложных объединений нескольких таблиц, которые впоследствии используются для усреднения значений нескольких полей.

Эти базы данных на основе SQL предоставляют гарантии ACID для транзакций. Выполнение множества операций в рамках транзакции атомарно (Atomic, буква A в аббревиатуре ACID), что означает выполнение либо всех операций, либо отказ от их выполнения. Постоянство (Consistency, буква C) подразумевает уверенность в том, что после выполнения транзакции база данных будет находиться в обычном состоянии и не будет повреждена. Изоляция (Isolation, буква I) обозначает уверенность в том, что если две транзакции будут работать с одной и той же записью, они не будут мешать друг другу. Долговечность (Durability, буква D, данная тема будет подробно освещена в следующем разделе) подразумевает уверенность в том, что как только транзакция будет завершена, измененные данные будут сохранены в надежном месте.

Предоставляющие гарантии ACID транзакции облегчают жизнь разработчикам, упрощая процесс выяснения состояния их данных. Представьте множество транзакций, каждая из которых состоит из множества шагов (т.е. сначала проверяется состояние банковского счета, затем с него списывается $60, после чего сумма обновляется). Предоставляющие гарантии ACID базы данных обычно ограничены в возможности изменения последовательности этих шагов с учетом необходимости предоставления корректного результата в ходе всех транзакций. Это требование корректности приводит к обычно неожиданным характеристикам производительности, причем медленная транзакция может перевести быструю транзакцию в состояние ожидания выполнения.

Большинство систем NoSQL рассматривает производительность как более важный аспект, нежели гарантии ACID, но предоставляет гарантии на уровне ключей: две операции с одним и тем же ключом будут выполнены последовательно для предотвращения серьезного повреждения пар ключ-значение. Для многих приложений это решение не вызовет серьезных проблем с корректностью работы и позволит чаще выполнять быстрые операции. Однако, такой подход требует большего количества решений в области архитектуры приложения и корректности его работы со стороны разработчика.

Система Redis является известным исключением из общей тенденции отказа от транзакций. При работе на одном сервере, она предоставляет команду MULTI для атомарного и постоянного комбинирования множества операций, а также команду WATCH для изоляции операций. Другие системы предоставляют низкоуровневые функции проверки и установки значения (test-and-set), реализующие в некоторой степени гарантии изоляции.

13.2.5. Хранилище данных без жестко заданной схемы

Одним из распространенных параметров многих систем NoSQL является отсутствие жесткого требования к использованию схем в базе данных. Даже в хранилищах документов и хранилищах, использующих семейства столбцов, свойства подобных объектов не обязаны быть одинаковыми. Этот подход имеет преимущество, заключающееся в требовании меньшего структурирования данных и меньших затрат производительности при модификации схем в реальном времени. Данное решение возлагает большую ответственность на разработчика приложений, который должен использовать более безопасные методы программирования. Например, является ли отсутствие свойства lastname с фамилией в записи работника ошибкой, которую нужно исправить, или вызвано обновлением схемы, которая в данный момент используется системой? Управление данными и схемами реализуется на уровне стандартного кода приложения после нескольких итераций проекта, работающего с системами NoSQL, поддерживающими хранилища без жестко установленных схем.

13.3. Долговечность хранения данных

В идеальном случае модификации данных в системе для их хранения должны быть незамедлительно сохранены и скопированы на множество узлов для предотвращения потерь данных. Однако, поддержка механизмов безопасного хранения данных противоречит высокой производительности, поэтому различные системы NoSQL предоставляют различные гарантии в отношении долговечности хранения данных для повышения производительности. Сценарии неполадок достаточно многочисленны и значительно отличаются друг от друга, при этом не все системы NoSQL в состоянии защитить вас от них.

Простым и часто встречающимся сценарием неполадки является перезагрузка сервера или отключение энергоснабжения. Долговечность хранения данных в такой ситуации зависит от того, скопированы ли данные из оперативной памяти на жесткий диск, которому не требуется питания для их хранения. На случай отказа жесткого диска данные копируются на сторонние устройства, которые могут быть другими жесткими дисками этой же машины (зеркалирование RAID) или другими машинами в сети. Однако, датацентр может не пережить ситуации, которая приводит к различным неполадкам (например, торнадо), поэтому некоторые организации доходят до того, что копируют данные на серверы датацентров, находящихся на расстоянии нескольких фронтов ураганов. Запись данных на жесткие диски и копирование данных на множество локальных серверов или серверов в удаленных датацентрах обходятся достаточно дорого, поэтому различные системы NoSQL меняют гарантии долговечности хранения данных на возможность повышения производительности.

13.3.1. Долговечность хранения данных на отдельном сервере

Простейшей технологией длительного хранения данных является технология длительного хранения данных на отдельном сервере, которая позволяет быть уверенным в том, что любые модификации данных будут доступны после перезагрузки сервера или отключения энергоснабжения. Обычно эта технология предполагает запись измененных данных на диск, что является слабым местом при работе под нагрузкой. Даже если вы используете функцию вашей операционной системы для записи данных в файл на диске, операционная система может добавить данные в буфер, избегая незамедлительной модификации файла на диске таким образом, что несколько операций записи могут быть сгруппированы в одну. Только осуществление системного вызова fsync заставит операционную систему предпринять попытку записи и удостовериться в том, что буферизованные данные модификаций находятся на диске.

Стандартные жесткие диски могут выполнить 100-200 операций случайного доступа (переходов) в секунду и ограничены скоростью последовательной записи, равной 30-100 МБ/с. Операции в оперативной памяти могут быть на порядки быстрее в обоих случаях. Эффективная технология длительного хранения данных подразумевает ограничение количества случайных операций записи, осуществляемых в вашей системе, и повышение количества последовательных операций записи на жесткий диск. В идеальном случае вы пожелаете добиться от системы минимизации количества операций записи между вызовами fsync, а также максимизации количества последовательных записей, и предпочтете никогда не сообщать пользователю о том, что его данные были успешно записаны на диск до момента осуществления записи с помощью вызова fsync. Давайте рассмотрим несколько техник повышения производительности операций, необходимых для реализации гарантий длительного хранения данных на отдельном сервере.

Контроль частоты использования вызова fsync

Система Memcached14 является примером системы, не предоставляющей гарантий длительного хранения данных, при этом использующей возможность осуществления чрезвычайно быстрых операций в пределах оперативной памяти. При перезагрузке сервера данные на этом сервере пропадают: это подходит для кэширования, но не подходит для длительного хранения данных.

Система Redis предоставляет разработчикам возможность изменения параметров использования системного вызова fsync. Разработчики могут установить принудительный вызов fsync после каждой операции записи, что является медленным и безопасным решением. Для лучшей производительности система Redis может вызывать fsync через каждые N секунд. При самом плохом сценарии вы потеряете данные, записанные в ходе осуществления операций в течение последних N секунд, что может быть допустимо для определенных случаев использования системы. Наконец, для случаев использования системы, в которых долговечность хранения данных не важна (сбор неточной статистики или использование системы Redis для кэширования) разработчик может полностью отключить использование системного вызова fsync: операционная система в конечном счете все равно запишет данные на диск, но невозможно гарантировать то, что данные будут записаны в определенный момент.

Увеличение количества последовательных записей с помощью журналирования

Некоторые структуры данных, такие, как бинарные деревья (B+Trees) позволяют системам NoSQL быстро извлекать данные с диска. Обновления этих структур приводят к обновлению случайных участков файлов, хранящих структуры данных, что в итоге приводит к нескольким случайным записям при обновлении данных в случае использования вызова fsync после каждого обновления. Для снижения количества случайных записей такие системы, как Cassandra, Hbase, Redis и Riak добавляют данные операций обновления в последовательно записываемый файл, называемый журналом. В то время, как при записи других используемых системой структур данных вызов fsync используется периодически, при записи данных в журнал вызов fsync используется постоянно. Рассматривая журнал как отправную точку для восстановления состояния базы данных после краха, эти хранилища способны превращать случайные обновления данных в последовательные.

Хотя такие системы NoSQL, как MongoDB и выполняют непосредственную запись структур данных, другие системы еще больше развивают идею использования журнала. Системы Cassandra и HBase используют заимствованную из системы BigTable технику объединения журнала и структур для поиска в рамках одного дерева слияния со структурой журнала (log-structured merge tree). Система Riak предоставляет аналогичные функции в рамках хэш-таблицы со структурой журнала (log-structured hash table). Система CouchDB использует модификацию традиционного бинарного дерева (B+Tree) так, что все изменения структуры данных добавляются к структуре на физическом устройстве хранения. С помощью этих техник удается достичь повышенной пропускной способности, но становится необходимым постоянное уплотнение данных журнала с целью предотвращения неконтролируемого роста его размера.

Повышение пропускной способности путем группировки операций записи

Система Cassandra группирует множество параллельных обновлений данных, выполненных в течение короткого промежутка времени, для единственного вызова fsync после их записи. Это архитектурное решение, называемое групповой записью (group commit) приводит к задержке при обновлении данных, так как пользователям приходится ждать выполнения нескольких параллельных обновлений данных для того, чтобы их обновление получило подтверждение. Повышение задержки позволяет повысить пропускную способность, так как множество записей в журнал может быть произведено с использованием единственного вызова fsync. Если рассматривать запись, то каждое обновление данных системой HBase производится с использованием низкоуровневого хранилища, предоставляемого распределенной файловой системой Hadoop (Hadoop Distributed File System - HDFS)15, для которой недавно были применены патчи, реализующие поддержку дополнения файлов с учетом использования вызова fsync и групповую запись.

13.3.2. Долговечность хранения данных на множестве серверов

Так как жесткие диски и компьютеры обычно после выхода из строя не подлежат восстановлению, копирование важных данных на сторонние компьютеры необходимо. Многие системы NoSQL предоставляют функции для хранения данных на множестве серверов.

Система Redis использует традиционный подход с ведущим и ведомыми серверами для копирования данных. Все операции, выполняемые с ведущим сервером, передаются в подобном журналу виде ведомым серверам, которые повторяют операции, используя свое собственное аппаратное обеспечение. Если ведущий сервер отказывает, ведомый сервер может начать работу, используя полученные от ведущего сервера данные состояния из журнала операций. Эта конфигурация может привести к потере некоторого объема данных, так как ведущий сервер не проверяет, записал ли данные в журнал ведомый сервер перед отправкой подтверждения выполнения операции пользователю. Система CouchDB использует упрощенный вариант аналогичной системы направленной репликации, в которой серверы могут быть настроены таким образом, что изменения документов будут копироваться в сторонние хранилища данных.

Система MongoDB предоставляет механизм наборов копий, в котором некоторое количество серверов ответственно за хранение каждого документа. MongoDB предоставляет разработчикам возможность проверять, обновляются ли все копии, или продолжать работу без проверки обновления всех копий с использованием новейших данных. Множество других распределенных хранилищ систем NoSQL поддерживает возможность копирования данных на множество серверов. Система HBase, основанная на HDFS, распределяет данные между серверами средствами файловой системы HDFS. Все операции записи повторяются двумя или большим количеством серверов HDFS перед возвращением управления пользователю, что позволяет быть уверенным в долговечности хранения данных на множестве серверов.

Системы Riak, Cassnadra и Voldemort поддерживают более настраиваемые формы репликации. При наличии небольших отличий, все три системы позволяют пользователю задать значение параметра N, определяющего количество машин, которые в конечном итоге должны хранить копию данных, а также параметр W<N, который устанавливает количество машин, которые должны убеждаться в завершении записи перед возвращением управления пользователю.

Для преодоления ситуации, при которой весь датацентр прекращает работу, необходима возможность копирования данных на серверы других датацентров. Системы Cassandra, HBase и Voldemort поддерживают независимые от стоек (rack aware) конфигурации, позволяющие задавать стойку или датацентр, в которых расположены различные машины. В общем случае, блокировка действий пользователя до того момента, как удаленный сервер подтвердит факт обновления данных, приводит к появлению очень большой задержки. Данные для обновлений передаются без подтверждений при работе в сетях большого размера и сохранении резервных копий данных в отдельных датацентрах.

13.4. Масштабирование с целью повышения производительности

Только что обсудив работу в условиях неполадок, давайте представим более приятную ситуацию: успешное функционирование системы! Если созданная вами система успешно функционирует, ваше хранилище данных станет одним из компонентов системы, который снизит производительность под нагрузкой. Простым и не самым элегантным решением этой проблемы является расширение возможностей (scale up) существующего оборудования: вы можете вложить больше средств в оперативную память и жесткие диски для обработки нагрузок с использованием единственной машины. После этого успешного шага денежные вливания в более дорогое аппаратное обеспечение станут невозможными. В таком случае вам придется копировать данные и распространять запросы между несколькими машинами для распределения нагрузки. Этот подход называется распределением возможностей (scale out) и оценивается с помощью степени горизонтального масштабирования (horizontal scalability) вашей системы.

Идеальной степенью горизонтального масштабирования является линейная масштабируемость (linear scalability), при достижении которой удвоение количества машин, задействованных в вашей системе хранения данных, удваивает количество запросов, которое система в состоянии обработать. Ключевым фактором для достижения такой степени горизонтального масштабирования является способ распределения данных между серверами. Фрагментация системы является действием, направленным на разделение нагрузки чтения и записи данных между множеством машин с целью распределения возможностей вашей системы хранения данных. Фрагментация системы является фундаментальным понятием в рамках архитектур многих систем, а именно: Cassandra, HBase, Voldemort и Riak, а недавно также MongoDB и Redis. Некоторые проекты, такие, как CouchDB созданы с учетом производительности отдельного сервера и не предоставляют встроенных решений для фрагментации системы, но сторонние проекты предоставляют программные компоненты для координации, позволяющие распределить нагрузку между независимыми установками этой системы на множестве машин.

Давайте рассмотрим несколько взаимозаменяемых терминов, с которыми вы можете столкнуться. Мы будем использовать термины фрагментация системы и разделение системы для обозначения аналогичных действий. Термины машина, сервер или узел относятся к какому-либо физическому компьютеру, хранящему часть разделенных данных. Наконец, кластер или кольцо относятся к группе машин, которые участвуют в работе вашей системы хранения данных.

Фрагментация системы подразумевает то, что ни одна машина не должна обрабатывать нагрузку, создаваемую операциями записи всего набора данных, а также ни одна машина не может ответить на запрос всего набора данных. Большинство систем NoSQL использует ключи и в моделях данных и в моделях запросов, при этом очень малое количество запросов осуществляет доступ ко всему набору данных в любом случае. Так как основной метод доступа к данным, хранящимся этими системами, связан с использованием ключей, фрагментация системы также обычно реализуется на основе ключей: с помощью некоторой функции, принимающей ключ в качестве исходных данных, устанавливается машина, на которой будет храниться пара ключ-значение. Мы рассмотрим два метода создания сопоставления ключ-машина: метод хэш-разделения и метод разделения на основе диапазонов.

13.4.1. Не фрагментируйте систему без необходимости

Фрагментация усложняет систему и, если это возможно, вы должны избегать ее. Давайте рассмотрим два способа масштабирования без фрагментации: копирование читаемых данных (read replicas) и кэширование (caching).

Копирование читаемых данных

Многие системы хранения данных принимают большее количество запросов на чтение данных, чем на их запись. Простым решением в данном случае является копирование данных на множество машин. Все запросы записи все также будут отправляться ведущему серверу. Запросы чтения же будут отправляться машинам, хранящим копии данных, обычно немного устаревших по отношению к данным на ведущем сервере.

Если вы уже копируете ваши данные для длительного хранения на множестве серверов и используете конфигурацию ведущих и ведомых серверов, что типично для систем Redis, CouchDB или MongoDB, ведомые машины для обслуживания запросов чтения могут взять на себя часть нагрузки ведущего сервера. Некоторые запросы, такие, как запросы для создания отчетов о ваших наборах данных, которые могут быть требовательны к ресурсам и обычно не нуждаются в обновленной с точностью до секунды копии данных, могут быть выполнены в отношении копий данных на ведомых серверах. Как правило, чем менее жесткие требования вы предъявляете к актуальности данных, тем большее количество работы вы можете перенести на ведомые серверы, тем самым повысив производительность запросов чтения данных.

Кэширование

Кэширование наиболее популярных данных в вашей системе обычно работает на удивление хорошо. Система Memcached выделяет блоки памяти на множестве серверов для кэширования данных из вашего локального хранилища данных. Клиенты Memcached пользуются преимуществами нескольких приемов горизонтального масштабирования для разделения нагрузки между установками системы Memcached на различных серверах. Для добавления памяти в пул кэширования следует просто добавить другой узел с запущенной копией системы Memcached.

Так как система Memcached проектировалась для кэширования данных, она не так сложна в плане архитектуры, как решения для постоянного хранения данных, масштабируемые в зависимости от нагрузок. Перед рассмотрением более сложных решений подумайте о том, сможет ли кэширование поспособствовать решению ваших проблем с масштабированием. Кэширование не является исключительно временной мерой: компания Facebook использует систему Memcached для работы с объемами оперативной памяти в десятки терабайт!

Копирование читаемых данных и кэширование позволяют вам масштабировать высокие нагрузки, создаваемые запросами чтения данных. Однако, когда вы начнете повышать частоту использования операций записи и обновления ваших данных, вы будете также повышать нагрузку на ведущий сервер, хранящий все обновленные данные. В оставшейся части данного раздела мы рассмотрим техники фрагментации нагрузки, создаваемой операциями записи, с использованием множества серверов.

13.4.2. Фрагментация системы с помощью программ для координации запросов

Проект CouchDB развивается в направлении функционирования на единственном сервере. Два проекта, Lounge и BigCouch, упрощают операцию фрагментации нагрузок на систему CouchDB с помощью внешнего прокси-сервера, который работает как система предварительной обработки запросов, передаваемых функционирующим системам CouchDB. В данной архитектуре отдельные установки системы не подозревают о существовании друг друга. Программа координации распределяет запросы между отдельными установками системы CouchDB на основе ключей запрашиваемых документов.

Компания Twitter встроила механизмы фрагментации нагрузок и копирования данных с фреймворк для координации с названием Gizzard. Фреймворк Gizzard использует отдельные хранилища данных любых типов - вы можете использовать слой совместимости системами хранения данных SQL и NoSQL - и объединяет их в деревья любой глубины для разделения ключей на основе диапазонов ключей. Для снижения восприимчивости к неполадкам фреймворк Gizzard16 может быть настроен таким образом, что данные для одного и того же диапазона ключей будут копироваться на множество физических машин.

13.4.3. Последовательные хэш-кольца

Качественные хэш-функции распределяют набор ключей равномерно. Это делает их мощным инструментом для распределения пар ключ-значение между множеством серверов. В научной технической литературе подробно описана техника под названием "последовательное хэширование" (consistent hashing), которая впервые была применена для организации хранилищ данных в рамках систем с названием "распределенные хэш-таблицы" (distributed hash tables - DHTs). Системы NoSQL, построенные на принципах системы Dynamo от компании Amazon применяют данную технику распределения, а также она используется в системах Cassandra, Voldemort и Riak.

Хэш-кольца в примере

Кольцо распределенной хэш-таблицы
Рисунок 13.1: Кольцо распределенной хэш-таблицы

Последовательные хэш-кольца работают следующим образом. Представим, что мы используем хэш-функцию H, которая производит равномерное распределение ключей, ставя их в соответствие большим целочисленным значениям. Мы можем сформировать кольцо из чисел в диапазоне [1, L], которое замыкается и может указать на позицию числа, полученного с использованием функции H(ключ) mod L для какого-либо относительно большого целочисленного значения L. С помощью данной функции любой ключ будет поставлен в соответствие значению из диапазона [1, L]. Последовательное хэш-кольцо для серверов формируется путем получения уникального идентификатора каждого сервера (например, его IP-адреса) и применения к нему функции H. Вы можете интуитивно понять принцип работы данного алгоритма, обратившись к сформированному из пяти серверов (A-E) хэш-кольцу на Рисунке 13.1.

В данном случае мы выбираем значение L=1000. Давайте представим, что H(A) mod L = 7, H(B) mod L = 234, H(C) mod L = 447, H(D) mod L = 660 и H(E) mod L = 875. Теперь мы можем сказать, на каком сервере должен находиться ключ. Для этого мы поставим в соответствие всем ключам серверы, определяя, попадает ли ключ в диапазон значений, соответствующих каждому из серверов. Например, сервер A отвечает за хранение ключей, хэш-значения которых находятся в диапазоне [7, 233], а E отвечает за хранение ключей с хэш-значениями в диапазоне [875, 6] (этот диапазон пересекает значение 1000). Таким образом, если H('employee30') mod L = 899, то этот ключ будет храниться на сервере E, а если H('employee31') mod L = 234, то ключ будет храниться на сервере B.

Копирование данных

Копирование для длительного хранения данных на множестве серверов осуществляется путем передачи ключей и значений из одного ассоциированного с сервером диапазона серверам, следующим далее в кольце. Например, при трехкратной репликации ключи, соответствующие диапазону [7, 233], будут храниться на серверах A, B и C. Если сервер A прекратит работу из-за неполадок, соседние серверы B и C примут на себя предназначенную для этого сервера нагрузку. Некоторые архитектуры предусматривают возможность временного копирования данных на сервер E и переноса на него нагрузки сервера A, после чего диапазон значений сервера E будет расширен для включения в него значений, раньше соответствующих серверу A.

Достижение лучшего распределения

Хотя хэширование и является статистически эффективным методом равномерного распределения пространства ключей, обычно требуется большое количество серверов для того, чтобы это распределение стало действительно равномерным. К сожалению, мы обычно начинаем работу с малого количества серверов, которые не идеально отделяются друг от друга с помощью данной хэш-функции. В нашем примере длина диапазона ключей сервера A равна 277 и в то же время длина диапазона ключей сервера E равна 132. Это обстоятельство ведет к неравномерной нагрузке на серверы. Также оно осложняет процесс переноса функций с одного сервера на другой в случае неполадок, так как соседний сервер может внезапно получить контроль над всем диапазоном вышедшего из строя сервера.

Для решения проблемы неравномерности диапазонов ключей многие системы, включая Riak, создают по нескольку "виртуальных" узлов на физической машине. Например, при наличии 4 виртуальных узлов сервер A будет функционировать как серверы A_1, A_2, A_3 и A_4. Каждый виртуальный узел использует для хэширования различные значения, увеличивая вероятность управления ключами, распределенными по различным частям пространства ключей. Система Voldemort использует аналогичный подход, при котором количество диапазонов значений настраивается вручную и обычно больше количества серверов, в результате каждый сервер получает несколько небольших диапазонов значений.

Система Cassandra не ставит в соответствие каждому серверу множество небольших диапазонов, что иногда приводит к неравномерному распределению диапазонов ключей. Для балансировки нагрузки в Cassandra используется асинхронный процесс, который динамически определяет расположение серверов на кольце в зависимости от нагрузки на них в течение прошедшего времени.

13.4.4. Распределение с использованием диапазонов

В случае применения техники распределения с использованием диапазонов для фрагментации нагрузки некоторые машины, обслуживающие вашу систему, хранят метаданные с указанием на то, какие серверы хранят те или иные диапазоны ключей. Эти метаданные используются для поиска расположения ключа и поиска диапазонов, соответствующих серверам. Аналогично подходу с использованием последовательных хэш-колец, при распределении с использованием диапазонов пространство ключей разделяется на диапазоны, причем каждый диапазон ключей управляется одной машиной и возможно копируется на другие. В отличие от подхода с использованием последовательных хэш-колец, два ключа, находящиеся друг рядом с другом после сортировки, скорее всего, окажутся в одном и том же диапазоне. Это обстоятельство уменьшает объем метаданных для поиска диапазонов, так как большие диапазоны могут быть представлены в сжатом виде с помощью маркеров [начало, конец].

С введением активного механизма учета записей о соответствии диапазонов серверам, подход разделения диапазонов позволяет более точно выполнять снижение нагрузки на и без того загруженные серверы. Если в определенном диапазоне ключей фиксируется более высокий, чем в других диапазонах трафик, менеджер нагрузок может уменьшить размер диапазона для сервера или снизить количество обслуживаемых сервером фрагментов данных. Появившаяся свобода активного управления нагрузками достигается за счет введения дополнительных архитектурных компонентов для мониторинга и маршрутизации запросов.

Подход системы BigTable

Документация системы BigTable компании Google описывает иерархическую технику распределения данных в объекты (tablets) с использованием диапазонов. Объект хранит диапазон ключей и значений строки в рамках семейства столбцов. Он осуществляет поддержку всех необходимых журналов и структур данных для ответов на запросы о ключах в соответствующем диапазоне. Серверы объектов обслуживают множество объектов в зависимости от рабочей загрузки каждого из них.

Размер каждого объекта поддерживается в пределах 100-200 MB. Так как объекты могут изменять размер, два небольших объекта для примыкающих диапазонов ключей могут быть объединены, а также объект большого размера может быть разделен на два объекта. Ведущий сервер анализирует размер объекта, загрузку и доступность сервера объектов. Ведущий сервер устанавливает какой сервер объектов обслуживает объекты в любой момент времени.

Распределение с использованием диапазонов системы на основе BigTable
Рисунок 13.2: Распределение с использованием диапазонов системы на основе BigTable

Центральный сервер поддерживает соответствие объектов в таблице метаданных. Так как эти метаданные могут достигать больших объемов, таблица метаданных также использует фрагментацию в виде объектов, которые ставят в соответствие диапазоны ключей объектам и обозначают серверы объектов, ответственные за работу с этими диапазонами. Этот подход приводит необходимости преодоления трехслойной иерархической структуры для нахождения ключа на хранящем его сервере объектов, как показано на Рисунке 13.2.

Давайте рассмотрим пример. Клиент, ищущий ключ 900 отправляет запрос серверу A, который хранит объект для метаданных уровня 0. Этот объект идентифицирует объект метаданных уровня 1 на сервере B, содержащий диапазоны ключей 500-1500. Клиент отправляет запрос серверу B с ключом, который отвечает, что объект, содержащий ключи 850-950 найден в объекте на сервере C. Наконец, клиент отправляет запрос с требованием ключа на сервер C и получает данные строки в ответ. Объекты метаданных уровней 0 и 1 могут кэшироваться клиентом, который хочет избежать создания излишней нагрузки на серверы объектов ввиду отправки повторяющихся запросов. Документация системы BigTable указывает на то, что эта трехуровневая иерархическая структура может использовать для работы 261 байт полезного дискового пространства, создавая объекты размером в 128 MB.

Обработка ошибок

Ведущий сервер является единой точкой отказа в рамках архитектуры BigTable, но он может временно прекращать работу, не влияя на запросы к серверам объектов. Если сервер объектов прекращает работу в момент обработки запросов объектов, ведущий сервер должен определить это и переназначить его объекты, при этом, запросы будут завершаться ошибкой в течение некоторого промежутка времени.

В общем случае для определения наличия и обработки неполадок машин документация BigTable рекомендует использовать Chubby, распределенную систему блокировок для управления принадлежностью к группам и доступностью сервера. Система ZooKeeper17 является реализацией Chubby с открытым исходным кодом и некоторые проекты на основе Hadoop используют ее для управления вторичными ведущими серверами и переназначения серверов объектов.

Проекты NoSQL, применяющие распределение с использованием диапазонов

Система HBase применяет иерархический подход для реализации системы распределения с использованием диапазонов BigTable. Данные объектов хранятся в распределенной файловой системе Hadoop (HDFS). Файловая система HDFS выполняет копирование данных и проверяет копии на наличие повреждений, оставляя для серверов объектов задачи по обработке запросов, обновлению структур хранилища и инициированию разделения и объединения объектов.

Система MongoDB использует технологию распределения данных с использованием диапазонов, аналогичную таковой в системе BigTable. Несколько конфигурационных узлов хранят и управляют таблицами перенаправлений, которые содержат информацию о том, какой из серверов хранения данных ответственен за какой из диапазонов ключей. Эти конфигурационные узлы синхронизируют данные с использованием протокола под названием "двухфазная передача" (two-phase commit) и работают в качестве гибридного решения, состоящего из ведущего сервера BigTable для указания диапазонов и системы Chubby для управления конфигурацией в условиях высокой доступности. Отдельные процессы маршрутизации, не использующие состояния, отслеживают последние запросы изменения конфигурации маршрутизации и осуществляют перенаправления запросов к соответствующим серверам хранилища данных для получения ключей. Серверы хранилища данных распределены в группы серверов копий данных для осуществления репликации.

Система Cassandra предоставляет сохраняющую последовательность систему распределения, используемую в том случае, если желательно разрешить быстрые сканирования диапазонов с доступом к ваших данных. Серверы системы Cassandra все также объединены в кольцо с использованием последовательного хэширования, но вместо хэширования пар ключ-значение и сопоставления полученного результата с кольцом для установления сервера, которому должны соответствовать эти данные, ключ просто ставится в соответствие серверу, который контролирует диапазон значений, содержавший ключ входил изначально. Например, ключи 20 и 21 оба будут поставлены в соответствие серверу A в нашем последовательном хэш-кольце на Рисунке 13.1, вместо хэширования и случайного распределения по кольцу.

Фреймворк Gizzard компании Twitter для управления распределенными и скопированными данными с использованием множества систем применяет распределение с использованием диапазонов для фрагментации данных. Маршрутизирующие серверы из иерархий любых глубин ставят диапазоны ключей в соответствие серверам, стоящим ниже их по иерархии. Эти серверы либо хранят данные для ключей в соответствующем им диапазоне, либо перенаправляют запросы маршрутизирующим серверам другого уровня. Репликация в данной модели реализуется с помощью отправки обновлений на множество машин для диапазона ключей. Серверы маршрутизации системы Gizzard обрабатывают ошибки записи способом, отличным от применяемых другими системами NoSQL. Система Gizzard требует, чтобы системные архитекторы сделали все обновления многократными (они могут выполняться дважды). В случае, когда узел хранилища не может выполнить запрос, узлы маршрутизации кэшируют и периодически отправляют обновления узлу до тех пор, пока он не подтвердит успешное завершение обновления данных.

13.4.5. Какую систему распределения данных следует использовать

Какую систему распределения данных предпочтительнее выбрать после рассмотрения подходов к фрагментации данных на основе хэшей и диапазонов? Это зависит от условий ее использования. Распределение с использованием диапазонов является очевидным выбором в случае частого сканирования ключей для доступа к данным. Так как вы читаете данные используя ключи, вы не будете осуществлять обращения к случайным узлам сети, усиливая тем самым загрузку на нее. Но если вам не требуется сканировать диапазоны данных, то какую схему их фрагментации следует использовать?

Разделение данных с использованием хэшей позволяет достичь разумной степени их распределения по узлам, а случайные диспропорции в распределении могут быть сглажены с помощью создания виртуальных узлов. В схеме распределения данных с помощью хэширования маршрутизация реализуется достаточно просто: в большей части случаев хэш-функция может выполняться клиентами для поиска необходимого сервера. В случае применения более сложных схем ребалансировки поиск необходимого сервера для ключа становится более сложной задачей.

Распределение с использованием диапазонов требует дополнительных затрат ресурсов на поддержку серверов маршрутизации и конфигурации, которые будут работать под большими нагрузками и станут центральными точками отказа в случае отсутствия относительно сложных схем обработки ошибок. Однако, при правильной настройке, данные, распределенные с использованием диапазонов, для балансировки нагрузки будут разделены на небольшие фрагменты, которые могут быть перемещены в другие диапазоны при высокой нагрузке на систему. В случае отключения сервера соответствующие ему диапазоны могут быть разделены между множеством других серверов вместо создания дополнительной нагрузки на его соседние серверы в период неработоспособности.

13.5. Согласованность данных

После обсуждения достоинств техник копирования данных на множество машин для повышения долговечности их хранения и распределения нагрузки, пришло время открыть вам секрет: хранение копий ваших данных на множестве машин при условии их идентичности является сложной задачей. На практике копии повреждаются и не соответствуют друг другу, теряются без возможности последующего восстановления, сети не позволяют синхронизировать наборы копий, а также передаваемые между машинами сообщения доставляются с задержкой или теряются. Существует два основных подхода к обеспечению согласованности данных в экосистеме NoSQL. Первый подход подразумевает строгую согласованность (strong consistency) данных, при которой все копии остаются синхронизированными. Второй подход подразумевает конечную согласованность (eventual consistency), при которой копии могут быть не идентичными, но в конечном счете все же стать таковыми. Для начала давайте разберемся с тем, почему второй вариант является допустимым решением, рассмотрев фундаментальное понятие систем распределенных вычислений. После этого мы перейдем к подробному рассмотрению каждого из подходов.

13.5.1. Немного о CAP

Почему мы учитываем не все гарантии строгой согласованности наших данных? Все сводится параметрам распределенных систем, спроектированных для работы с современным сетевым оборудованием. Идея была впервые предложена Eric Brewer в виде теоремы CAP (CAP Theorem), после чего была также представлена в работе от Gilbert и Lynch [GL02]. Теорема впервые описывала три параметра распределенных систем, из которых и был сформирован акроним CAP:

Теорема говорит о том, что система хранения данных, которая функционирует на множестве компьютеров может обладать только двумя из этих параметров в ущерб третьему. Также нам приходится реализовывать системы с возможностью разделения. При работе с современным оборудованием сетей с использованием современных протоколов сообщений пакеты могут теряться, свитчи могут выходить из строя и не существует способа для получения информации о неработоспособности сети или сервера. Все системы NoSQL должны обладать возможностью разделения. Остается выбор между согласованностью и доступностью. Ни одна система NoSQL не может обладать двумя этими параметрами одновременно.

Выбор в пользу согласованности подразумевает то, что ваши копии данных не будут рассинхронизированы. Простейшим путем достижения согласованности является требование подтверждения всех обновлений, производимых в отношении копий данных. В случае недоступности копии и невозможности ее обновления, вы можете снизить доступность соответствующих ее ключам данных. Это значит, что до того момента, как все копии будут восстановлены и доступны, пользователь не получит подтверждения успешного завершения операции обновления их данных. Следовательно, выбор в пользу согласованности является выбором в пользу отсутствия круглосуточной доступности каждого элемента набора данных.

Выбор в пользу доступности подразумевает, что при выполнении операции пользователем копии должны предоставить свои данные независимо от состояния других копий. Это может привести к расхождению в согласованности данных копий, так как в данном случае не требуется подтверждения всех их обновлений и некоторые копии могут не получить всех обновлений.

Положения теоремы CAP ведут к появлению подходов строгой согласованности и конечной согласованности при разработке хранилищ данных NoSQL. Существуют и такие подходы, как облегченная согласованность и облегченная доступность, представленные в системе PNUTS [CRS+08] компании Yahoo!. Ни в одной из обсуждаемых нами систем NoSQL с открытым исходным кодом эта техника пока не реализована, поэтому мы не будем рассматривать ее более подробно.

13.5.2. Строгая согласованность данных

Системы, предусматривающие строгую согласованность данных, следят за тем, чтобы копии данных были идентичны и позволяли получить одно и то же значение ключа. Некоторые копии могут быть не синхронизированы с другими, но в момент, когда пользователь запрашивает значение для ключа employee30:salary, машины имеют возможность согласования значения, передаваемого пользователю. Принцип работы этого механизма лучше всего описывается с помощью чисел.

Например, мы копируем ключ на N машин. Какая-либо машина, возможно, одна из множества N, выступает в качестве координатора для каждого запроса. Координатор устанавливает факт того, что определенное количество машин из множества N приняло и подтвердило каждый из запросов. В момент, когда происходит запись обновленных данных для ключа, координатор не отправляет подтверждение завершения обновления пользователю до того момента, как как будет принято подтверждение получения обновлений группой из W копий. Когда пользователь хочет прочитать значение, соответствующее какому-либо ключу, координатор отправляет ответ, когда как минимум из R копий прочитано одно и то же значение. Мы говорим, что система использует строгую согласованность данных, если R+W>N.

Используя числа для иллюстрации данной идеи, представим, что мы копируем каждый ключ на N=3 машины (обозначим их A, B и C). Предположим, что ключ employee30:salary имеет начальное значение $20,000, но мы хотим повысить жалование сотрудника employee30 до $30,000. Давайте также установим требование, согласно которому как минимум W=2 машины из трех A, B или C должны отправить подтверждение выполнения каждого запроса записи ключа. Если машины A и B подтверждают выполнение запроса записи данных (employee30:salary, $30,000), координатор оповещает пользователя об успешном обновлении ключа employee30:salary. Предположим, что машина C никогда не принимала запрос записи данных для ключа employee30:salary, поэтому все еще содержит значение $20,000. Когда координатор примет запрос чтения данных ключа employee30:salary, он отправит этот запрос всем 3 машинам:

Таким образом, для достижения строгой согласованности данных в этом случае нам потребовалось установить значение R=2, следовательно, R+W=4.

Что же случится, если W копий не будут обновлены в ходе выполнения запроса записи данных или R копий не будут прочитаны в ходе выполнения запроса чтения данных в случае необходимости использования строго согласованного ответа? Координатор может приостановить обработку запроса до истечения времени ожидания и в конечном счете отправить пользователю сообщение об ошибке или ожидать того, что ситуация разрешится сама собой. В любом случае, система окажется не в состоянии выполнить этот запрос, по крайней мере в течение некоторого промежутка времени.

Ваш выбор значений R и W влияет на то, как много машин смогут вести себя странно до того момента, как ваша система заблокирует возможность совершения различных действий с ключом. Если вы установите необходимость подтверждения записи данных для всех ваших копий, например, то будет справедливо равенство W=N и операции записи будут приостанавливаться или аварийно завершаться в случае невозможности работы с любой копией. Стандартным выбором является равенство R+W=N+1, соответствующее минимальным требованиями строгой согласованности данных, при этом позволяющее временные несоответствия между копиями. Многие системы, реализующие подход строгой согласованности данных, выбирают равенства W=N и R=1, так как в этом случае не придется рассчитывать на рассинхронизацию узлов.

Система HBase использует для хранения и копирования данных HDFS, распределенную прослойку для хранения данных. HDFS предоставляет гарантии строгой согласованности данных. В HDFS запись не может завершиться успешно до тех пор, пока данные не будут скопированы во все N (обычно 2 или 3) копий, поэтому справедливо равенство W=N. Чтение завершится успешно в случае доступности хотя бы одной копии, поэтому справедливо равенство R=1. Для предотвращения снижения производительности из-за интенсивных нагрузок записи, данные передаются от пользователя узлам с копиями асинхронно в параллельном режиме. Как только получено подтверждение о том, что все копии данных доставлены, финальный шаг добавления новых данных в систему выполняется атомарно и согласованно всеми узлами с копиями данных.

13.5.3. Конечная согласованность данных

Такие системы на основе Dynamo, как Voldemort, Cassandra и Riak позволяют пользователю задать необходимые значения N, R и W, даже при условии R+W<=N. Это значит, что пользователь может достичь и строгой и конечной согласованности данных. Если пользователь выбирает конечную согласованность данных, даже в том случае, когда разработчик стремится реализовать модель строгой согласованности данных, но значение W меньше N, существуют периоды, в течение которых копии могут быть не синхронизированы. Для реализации модели конечной согласованности данных копий эти системы применяют специальные инструменты для быстрого выявления устаревших копий. Для начала давайте рассмотрим вопрос о том, как различные системы определяют рассинхронизацию копий данных, после чего обсудим их механизмы синхронизации копий данных и наконец проясним некоторые методы для ускорения процесса синхронизации, реализованные на основе методов системы Dynamo.

Управление версиями и конфликты

Так как две копии могут содержать две различных версии значения для какого-либо ключа, механизмы контроля версий данных и поиска конфликтов очень важны. Системы на основе Dynamo используют механизм контроля версий под названием "вектор времени" (vetctor clocks). Вектор времени является вектором, поставленным в соответствие каждому ключу и содержащим счетчик для каждой копии. Например, если серверы A, B и C хранят три копии какого-либо ключа, вектор времени будет состоять из трех элементов (N_A, N_B, N_C) и инициализироваться с помощью значений (0, 0, 0).

Каждый раз, когда соответствующая ключу копия данных обновляется, счетчик вектора увеличивает значение. Если сервер B модифицирует ключ, который до этого имел версию (39, 1, 5) он изменит вектор времени до (39, 2, 5). Когда данные другой копии, скажем на сервере C, обновляются с использованием сервера B, происходит сравнение вектора времени сервера B с локальным вектором времени. В случаях, если счетчики локального вектора времени имеют меньшие значения, чем счетчики, полученные с сервера B, на локальном сервере хранится устаревшая копия данных, которая может быть перезаписана с использованием копии с сервера B. Если серверы B и C имеют счетчики, значения в каждом из которых больше других, скажем (39, 2, 5) и (39, 1, 6), серверы считают, что они получили различные, возможно не взаимозаменяемые обновления и констатируют наличие конфликта.

Разрешение конфликтов

Механизмы разрешения конфликтов отличаются в различных системах. Документация системы Dynamo возлагает задачу разрешения конфликтов на использующее систему хранения данных приложение. Две версии списка покупок могут быть объединены в одну без значительной потери данных, но две версии совместно редактируемого документа могут потребовать вмешательства человека для обзора и разрешения конфликта. Система Voldemort реализует эту модель, возвращая несколько копий данных ключа отправившему запрос клиентскому приложению в случае конфликта.

Система Cassandra, которая хранит метку времени для каждого ключа, использует наиболее позднюю версию данных ключа в случае конфликта двух версий. Эта возможность исключает необходимость дополнительного взаимодействия с клиентом и упрощает API. Также данное архитектурное решение затрудняет работу в ситуациях, когда конфликтующие данные могут быть объединены специальным образом, как в нашем примере со списком покупок или при реализации распределенных счетчиков. Система Riak позволяет использовать оба подхода, предоставляемых системами Voldemort и Cassandra. Система CouchDB предлагает гибридную модель: она определяет наличие конфликта и позволяет пользователям запрашивать соответствующие ключу конфликтующие данные для ручного исправления, но самостоятельно выбирает версию для возвращения данных пользователям до разрешения конфликтов.

Исправление при чтении

Если R копий при чтении координатором содержат не конфликтующие между собой данные, координатор может безопасно возвращать данные приложению. Координатор также может отметить факт рассинхронизации нескольких копий. Документация системы Dynamo рекомендует, а системы Cassandra, Riak и Voldemort реализуют технику с названием "исправление при чтении" (read repair) для разрешения таких ситуаций. В момент, когда координатор устанавливает наличие конфликта при чтении, даже если непротиворечивое значение отправлено пользователю, координатор начинает использовать протоколы разрешения конфликтов по отношению к конфликтующим копиям. Данное действие позволяет осуществить профилактическое разрешение конфликта с помощью небольшой дополнительной работы. Версии данных копий уже отосланы координатору, поэтому быстрейшее разрешение конфликта позволит снизить количество несоответствий в данных системы.

Передача данных с использование подсказок

Системы Cassandra, Riak и Voldemort используют технику под названием "передача данных с использованием подсказок" (hinted handoff) для повышения производительности операций записи в ситуациях, когда узел становится временно недоступным. Если одна из копий соответствующих ключу данных не может быть обновлена в из - за отсутствия ответа сервера на запрос записи, выбирается другой узел для временного переноса операций записи на его мощности. Записанные данные, предназначавшиеся для недоступного узла, хранятся отдельно и в тот момент, когда сервер резервных копий определяет, что до этого недоступный узел снова доступен, он передает все данные доступному серверу. Документация системы Dynamo использует подход "неполного кворума" (sloppy quorum) и добавляет количество записей с использованием подсказок к значению W, отражающему требуемое количество подтверждений операций записи. Системы Cassandra и Voldemort не добавляют количество записей с использованием подсказок к значению W и считают запись неудачной в случае недостаточного количества подтверждений операций записи W на заданные изначально серверы. Техника передачи данных с использованием подсказок также полезна и для этих систем, так как она ускоряет процесс восстановления данных во время перехода недоступного узла в работоспособное состояние.

Противодействие энтропии

Когда сервер с копией данных недоступен в течение длительного промежутка времени или машина, хранящая скопированные с помощью подсказки данные для недоступного сервера также становится недоступной, копии должны синхронизироваться друг с другом. В этом случае системы Cassandra и Riak реализуют процесс, аналогичный процессу в системе Dynamo и называемый "противодействием энтропии" (anti-entropy). В ходе этого процесса серверы обмениваются деревьями Меркле (Merkle Trees) для идентификации их участков диапазонов ключей, которые являются рассинхронизированными. Дерево Меркле является иерархической структурой для проверки данных на основе хэшей: если хэш всего пространства ключей не одинаков для двух копий, серверы будут обмениваться хэшами все меньших и меньших участков скопированных данных пространства ключей до того момента, как рассинхронизированные ключи будут идентифицированы. Этот подход снижает объем ненужных и в большей степени идентичных данных, передаваемых между серверами.

Техника Gossip

Наконец, с ростом распределенных систем становится все сложнее узнать то, как функционирует каждый их узлов системы. Три системы, построенные на основе Dynamo, используют для отслеживания состояния узлов старую простую технику, известную под названием "gossip". Периодически (каждую секунду или с подобной периодичностью) узел должен выбирать другой случайный узел, с которым он уже взаимодействовал для обмена данными о состоянии других узлов системы. С помощью этого обмена данными узлы узнают о том, какие из узлов находятся в неработоспособном состоянии, а также о том, куда перенаправить клиентов, ищущих ключ.

14.1 Введение

При разговоре о системах установки приложений обычно упоминают о двух подходах. Первый подход, характерный для Windows и Mac OS X, заключается в распространении самодостаточных пакетов приложений, процесс установки которых не должен зависеть от внешних факторов. Эта философия упрощает процесс управления приложениями: каждое приложение имеет свое отдельное "окружение" и его установка или удаление не влияет на другие части ОС. Если приложению для работы требуется нестандартная библиотека, эта библиотека включается в состав пакета для распространения приложения.

Второй подход, характерный для систем на основе ядра Linux, рассматривает программное обеспечение как набор небольших программных компонентов, называемых пакетами. Библиотеки добавляются в пакеты, причем любой пакет с библиотекой может зависеть от других пакетов. Процесс установки приложения может включать в себя процесс поиска и установки определенных версий множества других библиотек. Эти зависимости обычно доставляются из стандартного репозитория, содержащего тысячи пакетов. Данная философия обуславливает использование в дистрибутивах Linux таких сложных систем управления пакетами, как dpkg и RPM для отслеживания зависимостей и предотвращения установки двух приложений, использующих несовместимые версии одной и той же библиотеки.

У каждого подхода есть свои достоинства и недостатки. При использовании модульной системы, в которой каждый программный компонент может быть обновлен или заменен, снижаются затраты труда на обслуживание системы, так как каждая библиотека присутствует в единственном экземпляре и все приложения, использующие эту библиотеку, после обновления начнут использовать ее новую версию. Например, обновление безопасности системной библиотеки позволит одновременно защитить все приложения, использующие данную библиотеку, в отличие от случая, когда приложение использует отдельную копию библиотеки и установка обновления безопасности становится сложнее, особенно в том случае, когда различные приложения используют различные версии библиотеки.

Но модульность системы воспринимается как препятствие некоторыми разработчиками, так как они не могут контролировать работу своих приложений и их зависимостей. Для них было бы проще создать отдельный программный пакет для того, чтобы быть уверенными в том, что приложение будет работать в стабильном программном окружении, не попадая в "ад зависимостей" во время обновления системы.

Самодостаточные пакеты приложений также упрощают деятельность разработчика в том случае, когда он желает поддерживать несколько операционных систем. Некоторые проекты выпускаются в виде переносимых приложений, в которых исключено любое взаимодействие с файлами операционной системы, а работа осуществляется только с директорией приложения, даже в случае сохранения файлов журналов.

Система управления пакетами в Python разрабатывалась с использованием второго подхода - использовалось множество зависимостей для каждого пакета, а также система должна была быть так дружелюбна к разработчику, администратору и пользователю, как это возможно. К сожалению, она имела (и имеет) различные дефекты, обуславливающие и приводящие к разного рода проблемам: использованию неинтуитивных схем записи версий, наличию необрабатываемых файлов с данными, сложностям с повторной упаковкой и другим. Три года назад я и группа разработчиков Python решили повторно разработать эту систему для устранения вышеописанных проблем. Мы называем нашу группу Товариществом Разработчиков Системы Пакетов, а в данной главе будут описаны недостатки, которые мы пытались исправить, а также предложенные нами решения.

Терминология

При разговоре о пакете в Python имеется в виду директория с файлами исходного кода Python. Файлы исходного кода Python называются модулями. Такое описание приводит к непониманию при использовании слова "пакет", так как пакетом в ряде систем также называют релиз проекта.

Разработчики Python сами иногда путаются при разговоре об этом. Единственным путем преодоления этой двусмысленности является использование термина "пакет Python" при разговоре о директории, содержащей модули Python. Термин "релиз" используется для описания одной версии проекта, а термин "дистрибутив" - для бинарных файлов или исходных кодов релиза, например, в форме файла архива tar или zip.

14.2. Трудности разработчиков Python

Большинство разработчиков Python желают, чтобы их программы были работоспособны в любом программном окружении. Также обычно они предпочитают использовать комбинацию стандартных библиотек языка Python и системно-зависимых библиотек. Но пока вы не создаете отдельные пакеты для каждой из систем управления пакетами, вам приходится создавать релизы для Python - эти релизы создаются для установки с помощью соответствующей системы из состава Python независимо от используемой операционной системы и в теории должны соответствовать следующим требованиям:

Иногда выполнение этих условий попросту невозможно. Например, Plone (полнофункциональная система управления содержимым сайта на основе Python) использует сотни небольших библиотек Python, которые не всегда доступны в виде пакетов в существующих системах управления пакетами. Это означает, что система Plone должна быть самодостаточным приложением, в комплект поставки которого включаются все необходимые программные компоненты. Для этого используется система сборки zc.buildout, с помощью которой происходит копирование всех используемых программных компонентов и создание самодостаточного приложения, которое может работать на любой системе в рамках одной директории. На самом деле в итоге получается бинарный релиз, так как любой фрагмент кода на языке C может быть скомпилирован при сборке.

Это является большим подарком для разработчиков: им просто требуется описать зависимости с учетом приведенных ниже стандартов Python и использовать систему сборки zc.buildout для создания релиза приложения. Но, как обсуждалось ранее, данный тип релиза является отделенным от системы, поэтому не устроит большинство администраторов систем на основе Linux. Администраторы Windows-систем не будут против такой системы, но администраторы систем CentOS или Debian будут, так как эти системы выстраивают механизм управления пакетами исходя из предположения о том, что любой файл в системе является зарегистрированным, классифицированным и известным для инструментов администрирования.

Эти администраторы также захотят повторно упаковать ваше приложение в соответствии с используемыми ими стандартами. Нам необходимо ответить на вопрос: "Возможна ли система управления пакетам для Python, с помощью которой пакеты могли бы быть автоматически преобразованы в другие форматы?" В случае существования такой системы приложение или библиотека может быть установлено в любую операционную систему без необходимости повторной упаковки. В данном случае слово "автоматически" не обязательно означает, что вся работа по преобразованию должна вестись в рамках сценария: люди, работающие с системами управления пакетами RPM или dpkg скажут вам о том, что это невозможно - им всегда приходится добавлять специфические директивы при повторной упаковке проектов. Также они скажут вам о том, что они обычно испытывают сложности при повторной упаковке кода по той причине, что разработчики данного кода не учли нескольких основополагающих правил, применяемых при его упаковке.

Простой пример того, как вы можете создать трудности для людей, повторно упаковывающих ваш программный компонент, используя существующую систему управления пакетами Python: можно выпустить библиотеку с названием "MathUtils" версии "Fumanchu". Замечательному математику, разработавшему данную библиотеку, показалось забавным использовать имена своих котов в качестве версий проекта. Но как человек, сопровождающий пакет, узнает о том, что "Fumanchu" является именем второго кота, а первого кота зовут "Phil", поэтому версия "Fumanchu" вышла позднее версии "Phil"?

Может показаться странным, но такая ситуация вполне вероятна при использовании современных инструментов и стандартов. Плохо еще и то, что такие инструменты, как easy_install и pip используют нестандартные системы для отслеживания установленных файлов и сортируют версии "Fumanchu" и "Phil" в алфавитном порядке.

Другой проблемой является метод работы с файлами данных. Например, что будет, если ваше приложение использует базу данных SQLite? Если вы поместите базу данных в директорию вашего пакета, ваше приложение может работать некорректно, так как операционная система запрещает осуществлять запись в файлы, расположенные в данной части дерева файловой системы. Данный подход также нарушает предположения разработчиков операционных систем на основе ядра Linux о том, где должны находиться резервные копии данных приложений (в директории /var).

В реальности администраторы систем должны иметь возможность разместить файлы вашего приложения там, где они хотят, без нарушения работоспособности приложения, а вам необходимо довести до их сведения информацию об этих файлах. Поэтому давайте изменим формулировку вопроса: "Возможно ли создать такую систему управления пакетами в Python, которая бы предоставляла всю необходимую информацию для повторной упаковки приложения с использованием существующей сторонней системы управления пакетами без необходимости чтения кода и устраивала бы всех?"

14.3 Современная архитектура системы управления пакетами

Пакет Distutils, поставляемый в составе стандартной библиотеки Python подвержен описанным выше проблемам. Так как он является стандартным пакетом, разработчики либо используют его, мирясь с его дефектами, либо используют такие более сложные инструменты, как Setuptools, использующий Distutils в качестве основы и предоставляющий дополнительные функции, или Distribute, являющийся форком Setuptools. Также существует Pip, являющийся более сложным инструментом, созданным на основе Setuptools.

Однако, все эти новые инструменты основываются на Distutils и наследуют его недостатки. Попытки исправления недостатков Distutils предпринимались ранее, но его код настолько тесно связан с кодом других инструментов, что любое изменение даже внутренних интерфейсов вело к потенциальной регрессии всей экосистемы управления пакетами в Python.

Исходя из этого, мы решили прекратить работу над Distutils и начать разработку Distutils2 на основе той же кодовой базы, не особо беспокоясь об обратной совместимости. Для понимания того, что и как было изменено, давайте рассмотрим подробнее архитектуру пакета Distutils.

14.3.1. Краткое описание и дефекты архитектуры Distutils

Пакет Distutils поддерживает команды, каждая из которых реализована в виде класса с методом run, который может быть вызван с различными параметрами. В Distutils также реализован класс Distribution, содержащий глобальные значения, к которым может обратиться любой класс с реализацией команды.

Для использования пакета Distutils разработчик добавляет в проект единственный модуль Python, традиционно называемый setup.py. В данном модуле реализуется обращение к главной точке входа в Distutils: функции setup. Эта функция принимает множество параметров, которые впоследствии хранятся с помощью экземпляра класса Distribution и используются классами команд. Ниже приведен пример передачи нескольких стандартных параметров, таких, как название и версия проекта, а также списка модулей проекта:

from distutils.core import setup

setup(name='MyProject', version='1.0', py_modules=['mycode.py'])

Впоследствии данный модуль может использоваться для выполнения таких команд Distutils, как sdist, которая создает архив для распространения исходного кода и размещает его в директории dist:

$ python setup.py sdist

Используя тот же сценарий, вы можете произвести установку проекта с помощью команды install:

$ python setup.py install

Пакет Distutils исполняет такие дополнительные команды, как:

Также возможно получение дополнительной информации о проекте с помощью других параметров командной строки.

Таким образом, установка проекта или получение информации о нем всегда осуществляется путем использования пакета Distutils с помощью данного сценария. Например, для получения названия проекта можно использовать следующую команду:

$ python setup.py --name
MyProject

Следовательно, сценарий setup.py является инструментом для взаимодействия с проектом, независимо от того, нужно ли собрать, опубликовать или установить пакет. Разработчик описывает содержимое своего проекта с помощью параметров, передаваемых функции, а также использует этот сценарий для выполнения всех задач по работе с пакетом. Данный сценарий также используется для установки проекта в целевую систему.

Функции модуля setup.py
Рисунок 14.1: Функции модуля setup.py

Использование одного и того же модуля Python для упаковки, распространения и установки проекта является одним из главных дефектов архитектуры пакета Distutils. Например, если вы хотите получить название проекта lxml, сценарий setup.py выполнит множество действий помимо ожидаемого вывода строки:

$ python setup.py --name
Building lxml version 2.2.
NOTE: Trying to build without Cython, pre-generated 'src/lxml/lxml.etree.c'
needs to be available.
Using build configuration of libxslt 1.1.26
Building against libxml2/libxslt in the following directory: /usr/lib/lxml

Данная команда может даже не работать в некоторых проектах, так как их разработчики предполагают, что сценарий setup.py используется только для установки, поэтому остальные функции пакета Distutils будут использоваться ими только в период разработки. Множество вариантов использования сценария setup.py может просто привести в замешательство.

14.3 Современная архитектура системы управления пакетами

14.3.2. Метаданные и PyPI

Во время создания архива с исходным кодом для распространения пакет Distutils создает файл Metadata, соответствующий стандарту PEP 3141. Он содержит статическую копию метаданных, представленных такими полями, как название проекта или версия релиза. Основные поля файла метаданных:

Эти поля достаточно просто поставить в соответствие эквивалентным полям, используемым в других системах управления пакетами.

Каталог пакетов Python (The Pyhon Package Index - PyPI)2 является аналогичным CPAN центральным репозиторием пакетов, позволяющим регистрировать проекты и публиковать релизы с помощью команд пакета Distutils register и upload. С помощью команды register создается файл Metadata, который отправляется в PyPI, позволяя людям и таким используемым ими инструментам, как установщики, просматривать параметры проекта с помощью веб-страниц или веб-сервисов.

Репозиторий PyPI
Рисунок 14.2: Репозиторий PyPI

Вы можете просмотреть проекты, выбрав классификаторы из поля Classifiers и получить имя автора и строку URL для доступа к домашней странице проекта. Между тем, поле Requires может быть использовано для указания зависимостей проекта от модулей Python. Параметр requires может быть использован для добавления модуля в поле Requires метаданных проекта:

from distutils.core import setup

setup(name='foo', version='1.0', requires=['ldap'])

Объявление зависимости от модуля ldap носит исключительно декларативный характер: никакой из инструментов или обработчиков не будет проверять наличие данного модуля. Данное решение было бы удачным в том случае, если бы Python использовал зависимости на уровне модулей, задаваемые с помощью ключевого слова require аналогично Perl. После этого задача сценария установки заключалась бы в поиске зависимостей в PyPI и установке их; таким образом функционирует CPAN. Но это не возможно в Python, так как модуль с именем ldap может использоваться в любом проекте Python. Так как пакет Distutils позволяет публиковать проекты, которые могут содержать по нескольку пакетов и модулей, данное поле метаданных полностью бесполезно.

Другим дефектом файлов Metadata является тот факт, что они создаются с помощью сценария Python, следовательно, являются специфичными для платформы, на которой происходило создание. Например, проект, предоставляющий специфичные для Windows возможности, может использовать следующий файл setup.py:

from distutils.core import setup

setup(name='foo', version='1.0', requires=['win32com'])

Но этот файл предполагает работу проекта только под управлением Windows даже в том случае, когда предоставляются переносимые функции. Одним способом решения этой проблемы является использование специфичного для Windows параметра requires.

from distutils.core import setup
import sys

if sys.platform == 'win32':
    setup(name='foo', version='1.0', requires=['win32com'])
else:
    setup(name='foo', version='1.0')

Это объявление частично решает проблему. Вспомните о том, что сценарий используется для создания архивов с исходным кодом, которые впоследствии публикуются с помощью PyPI. Это значит, что статический файл Metadata, отправляющийся для публикации в PyPI, зависит от платформы, на которой происходит его формирование. Другими словами, нет способа для указания в поле метаданных того, что пакет зависим от платформы.

14.3 Современная архитектура системы управления пакетами

14.3.3. Архитектура PyPI

Процесс работы PyPI
Рисунок 14.3: Процесс работы PyPI

Как было сказано ранее, PyPI является центральным каталогом проектов на языке Python, с помощью которого люди могут просматривать существующие разделенные на категории проекты или регистрировать свои работы. Архивы с исходным кодом и бинарными файлами для распространения могут быть загружены и добавлены к существующему проекту, после чего становится возможным их скачивание для установки или изучения. PyPI также предоставляет веб-сервисы, которые могут использоваться такими инструментами, как установщики.

Регистрация проектов и загрузка архивов

Регистрация проекта в каталоге PyPI осуществляется с помощью команды register пакета Distutils. С помощью этой команды формируется POST-запрос, содержащий метаданные проекта вне зависимости от его версии. Запрос содержит заголовок авторизации, так как PyPI использует механизм аутентификации Basic для того, чтобы каждый зарегистрированный проект был ассоциирован с пользователем, ранее зарегистрировавшимся в PyPI. Аутентификационные данные хранятся в локальном файле настроек Distutils или вводятся после запроса при каждом вызове команды register. Пример использования этой команды:

$ python setup.py register
running register
Registering MPTools to http://pypi.python.org/pypi
Server response (200): OK

Каждый зарегистрированный проект получает веб-страницу, содержащую HTML-версию метаданных, и создатели пакетов могут загружать архивы для распространения в PyPI с помощью команды upload:

$ python setup.py sdist upload
running sdist
...
running upload
Submitting dist/mopytools-0.1.tar.gz to http://pypi.python.org/pypi
Server response (200): OK

Также возможно перенаправление пользователей на другой адрес с помощью поля метаданных Download-URL вместо загрузки файлов напрямую на PyPI.

Запросы к PyPI

Помимо HTML-страниц, генерируемых PyPI для пользователей, с помощью инструментов могут быть использованы две службы: простой протокол каталога и API XML-RPC.

Простой протокол каталога используется при доступе к адресу http://pypi.python.org/simple/, причем в ответ отправляется обычная HTML-страница, содержащая относительные ссылки на страницы каждого из зарегистрированных проектов:

<html><head><title>Simple Index</title></head><body>
:    :    :
<a href='MontyLingua/'>MontyLingua</a><br/>
<a href='mootiro_web/'>mootiro_web</a><br/>
<a href='Mopidy/'>Mopidy</a><br/>
<a href='mopowg/'>mopowg</a><br/>
<a href='MOPPY/'>MOPPY</a><br/>
<a href='MPTools/'>MPTools</a><br/>
<a href='morbid/'>morbid</a><br/>
<a href='Morelia/'>Morelia</a><br/>
<a href='morse/'>morse</a><br/>
:    :    :
</body></html>

Например, для проекта MPTools используется ссылка MPTools/, что означает наличие проекта в каталоге. Страница, на которую ведет ссылка, содержит ссылки на все относящиеся к проекту ресурсы:

Страница проекта MPTools содержит следующие ссылки:

<html><head><title>Links for MPTools</title></head>
<body><h1>Links for MPTools</h1>
<a href="../../packages/source/M/MPTools/MPTools-0.1.tar.gz">MPTools-0.1.tar.gz</a><br/>
<a href="http://bitbucket.org/tarek/mopytools" rel="homepage">0.1 home_page</a><br/>
</body></html>

Такие инструменты, как установщики, при необходимости проверки наличия проекта могут поискать его в каталоге или просто проверить, существует ли страница с адресом http://pypi.python.org/simple/НАЗВАНИЕ_ПРОЕКТА/.

Этот протокол имеет два недостатка. Во-первых, на данный момент каталог PyPI работает на одном сервере, и, хотя разработчики обычно имеют локальные копии его содержимого, мы сталкивались с несколькими случаями недоступности сервера за последние два года, что приводило к остановке работы с установщиками, использующими PyPI для получения списка зависимостей, необходимого для сборки проекта. Например, при сборке приложения Plone генерируется несколько сотен запросов к каталогу PyPI для получения всех необходимых данных, поэтому каталог PyPI в некоторых случаях может оказаться единой точкой отказа.

Во-вторых, в тех случаях, когда архивы для распространения не хранятся на сервере PyPI и на странице проекта приводится ссылка для их скачивания, установщикам приходится переходить по ссылке и рассчитывать на работоспособность стороннего сервера и наличие необходимого архива на этом сервере. Эти неопределенности снижают надежность функционирования процесса сборки при работе с простым протоколом каталога.

Целью простого протокола каталога является передача установщикам списка ссылок, необходимых им для установки проекта. С помощью этого протокола не передаются метаданные проекта; напротив, существуют методы XML-RPC для получения дополнительной информации о зарегистрированных проектах.

<<< import xmlrpclib
<<< import pprint
<<< client = xmlrpclib.ServerProxy('http://pypi.python.org/pypi')
<<< client.package_releases('MPTools')
['0.1']
<<< pprint.pprint(client.release_urls('MPTools', '0.1'))
[{'comment_text': &rquot;,
'downloads': 28,
'filename': 'MPTools-0.1.tar.gz',
'has_sig': False,
'md5_digest': '6b06752d62c4bffe1fb65cd5c9b7111a',
'packagetype': 'sdist',
'python_version': 'source',
'size': 3684,
'upload_time': ,
'url': 'http://pypi.python.org/packages/source/M/MPTools/MPTools-0.1.tar.gz'}]
>>> pprint.pprint(client.release_data('MPTools', '0.1'))
{'author': 'Tarek Ziade',
'author_email': 'tarek@mozilla.com',
'classifiers': [],
'description': 'UNKNOWN',
'download_url': 'UNKNOWN',
'home_page': 'http://bitbucket.org/tarek/mopytools',
'keywords': None,
'license': 'UNKNOWN',
'maintainer': None,
'maintainer_email': None,
'name': 'MPTools',
'package_url': 'http://pypi.python.org/pypi/MPTools',
'platform': 'UNKNOWN',
'release_url': 'http://pypi.python.org/pypi/MPTools/0.1',
'requires_python': None,
'stable_version': None,
'summary': 'Set of tools to build Mozilla Services apps',
'version': '0.1'}

Недостатком этого подхода является тот факт, что часть передаваемой посредством API XML-RPC информации следовало бы хранить в статических файлах и предоставлять на странице проекта по простому протоколу каталога для упрощения работы клиентских инструментов. Это решение позволило бы также сократить объем дополнительной работы, выполняемой PyPI для обработки этих запросов. Неплохо иметь доступ к динамически изменяющимся данным, таким, как количество загрузок архива для распространения для их публикации на специализированном веб-сервисе, но не имеет смысла использовать две различные службы для для получения статических данных, относящихся к проекту.

14.3 Современная архитектура системы управления пакетами

14.3.4. Архитектура системы установки Python

Если вы устанавливаете проект на языке Python с помощью команды python setup.py install, пакет Distutils, включенный в стандартную библиотеку, скопирует файлы в вашу систему.

С версии Python 2.5 файлы метаданных копируются вместе с модулями и пакетами в файлы с именами проект-версия.egg-info. Например, для проекта virtualenv будет использоваться имя файла virtualenv-1.4.9.egg-info. Эти файлы могут использоваться в качестве базы данных установленных проектов, так как в ходе их обхода становится возможным составление списка проектов вместе с их версиями. Однако, установщик из пакета Distutils не сохраняет список файлов, установленных в систему. Другими словами, нет способа удаления всех файлов, скопированных в систему. Это досадно, так как команда install поддерживает параметр --record, который может использоваться для записи списка установленных файлов в текстовый файл. Как бы то ни было, этот параметр не используется по умолчанию и в документации пакета Distutils содержится лишь краткое упоминание о нем.

14.3 Современная архитектура системы управления пакетами

14.3.5. Setuptools, Pip и аналогичные проекты

Как упоминалось во введении, в рамках некоторых проектов предпринимались попытки исправления определенных недоработок пакета Distutils с переменным успехом.

Вопрос зависимостей

Каталог PyPI позволяет разработчикам публиковать проекты на языке Python, содержащие несколько модулей для организации пакетов Python. Но в то же время проекты должны объявлять зависимости на уровне модулей с помощью директивы Require. Оба подхода разумны, но вот их комбинация - нет.

Правильным решением было бы объявление зависимостей на уровне проекта, что и было сделано в проекте Setuptools, добавившим эту функцию к Distutils. Он также предоставлял сценарий easy_install для автоматического получения и установки зависимостей путем поиска в каталоге PyPI. На практике зависимости уровня модулей никогда не использовались в полной мере, а вместо них использовались расширения Setuptools. Но так как эти параметры были специфичными для Setuptools и игнорировались Distutils или PyPI, в рамках пакета Setuptools был создан собственный стандарт, ставший решением, исправляющим недоработку в архитектуре системы управления пакетами.

Сценарию easy_install приходится скачивать архив проекта и запускать сценарий setup.py из его состава для получения необходимых метаданных, а также ему приходится повторять этот процесс для каждой зависимости. Граф зависимостей строится последовательно после каждого скачивания.

Даже если метаданные приняты в каталог PyPI и доступны в сети, сценарию easy_install также будет необходимо скачивать все архивы, ведь, как говорилось ранее, публикуемые с помощью PyPI метаданные зависят от платформы, которая использовалась для их загрузки и может отличаться от целевой платформы. Тем не менее, такая возможность установки проекта вместе с его зависимостями была достаточна в 90% случаев и была замечательной функцией. Поэтому пакет Setuptools получил широкое распространение, хотя он и был подвержен другим недостаткам:

Вопрос удаления файлов

Пакет Setuptools не предоставляет инструмента для удаления файлов, хотя его специфические метаданные и могут содержать список установленных файлов. Проект Pip, с другой стороны, расширил метаданные Setuptools для записи списка установленных файлов, и, таким образом добавил возможность удаления файлов. Однако, в данном случае использовался еще один набор специфических метаданных, поэтому отдельная установка Python должна была содержать:

14.3 Современная архитектура системы управления пакетами

14.3.6. Как насчет файлов данных?

В Distutils файлы данных могут быть установлены в любую директорию. Если вы зададите список файлов данных пакета в сценарии setup.py подобным образом:

setup(...,
  packages=['mypkg'],
  package_dir={'mypkg': 'src/mypkg'},
  package_data={'mypkg': ['data/*.dat']},
  )

то все файлы с расширением .dat в проекте mypkg будут включены в архив для распространения и в конечном счете установлены вместе с модулями Python с помощью системы установки.

Для файлов, устанавливаемых вне директории проекта Python, существует другой параметр, позволяющий хранить файлы в архиве, но размещать их в заданных директориях при установке:

setup(...,
    data_files=[('bitmaps', ['bm/b1.gif', 'bm/b2.gif']),
                ('config', ['cfg/data.cfg']),
                ('/etc/init.d', ['init-script'])]
    )

Это очень плохие новости для поддерживающих пакеты для операционных систем лиц по нескольким причинам:

Человек, желающий повторно упаковать проект с такими файлами, не имеет иного пути, кроме как разработать патч для файла setup.py для того, чтобы он работал так, как требуется на данной платформе. Для этого ему придется исследовать код и заменить каждую строку, где используются эти файлы, так как разработчик задал их расположение в системе. Проекты Setuptools и Pip не улучшили эту ситуацию.

14.4. Усовершенствованные стандарты

Мы закончили рассмотрение запутанного и непоследовательного окружения для управления пакетами, в котором все действия выполняются с помощью единственного модуля Python с использованием неполной формы метаданных и отсутствием возможности для описания всех файлов проекта. Теперь поговорим о том, как улучшить ситуацию.

14.4.1. Метаданные

Первым шагом является усовершенствование стандарта метаданных. Стандарт PEP 345 описывает новую версию формата метаданных, которая предусматривает:

Версия

Одной из целей изменения стандарта метаданных является предоставление возможности всем работающим с проектами Python инструментам классифицировать их аналогичным образом. В случае версий это означает, что каждый инструмент должен быть в состоянии установить то, что версия "1.1" была выпущена после версии "1.0". Но если используются специфические схемы указания версий, эта задача значительно усложняется.

Единственной возможностью установления постоянного формата версий является публикация стандарта, которому будут следовать проекты. Выбранная нами схема является классической схемой на основе последовательности версий. Как описано в стандарте PEP 386, для указания версий используется следующий формат строки:

N.N[.N]+[{a|b|c|rc}N[.N]+][.postN][.devN]

где:

В зависимости от процесса разработки, обозначения dev и post могут использоваться для всех промежуточных версий, выпускаемых между двумя финальными релизами. Для большей части релизов в процессе разработки используется обозначение dev.

Используя эту схему, стандарт PEP 386 определяет строгую последовательность:

Ниже приведен пример указания версий:

1.0a1 < 1.0a2.dev456 < 1.0a2 < 1.0a2.1.dev456
  < 1.0a2.1 < 1.0b1.dev456 < 1.0b2 < 1.0b2.post345
    < 1.0c1.dev456 < 1.0c1 < 1.0.dev456 < 1.0
      < 1.0.post456.dev34 < 1.0.post456

Целью разработки этой схемы было простое преобразование версий пакетов Python в версии для других систем управления пакетами. В данный момент каталог PyPI отклоняет любые загружаемые проекты, использующие стандарт PEP 345 для метаданных с версией, не соответствующей стандарту PEP 386.

Зависимости

Стандарт PEP 345 описывает три новых поля, заменяющих поля из стандарта PEP 314 Requires, Provides и Obsoletes. Этими полями являются Requires-Dist, Provides-Dist и Obsoletes-Dist, которые могут использоваться по нескольку раз в метаданных.

Каждая строка поля Requires-Dist должна содержать название другого проекта в формате Distutils, требуемого для работы данного проекта. Формат строки идентичен формату названия проекта в Distutils (т.е. формату, используемому при объявлении названия проекта в поле Name), после которого в скобках может следовать объявление версии. Эти названия проектов в формате Distutils должны соответствовать названиям проектов, используемым в PyPI, а объявления версий - соответствовать стандарту PEP 386. Некоторые примеры приведены ниже:

Requires-Dist: pkginfo
Requires-Dist: PasteDeploy
Requires-Dist: zope.interface (>3.5.0)

Поле Provides-Dist используется для указания дополнительных названий проектов в рамках данного проекта. Оно полезно в том случае, когда несколько проектов объединяются. Например, проект ZODB может включать в свой состав проект transaction и использовать следующее объявление:

Provides-Dist: transaction

Поле Obsoletes-Dist полезно в том случае, когда необходимо указать на то, что версия другого проекта становится устаревшей после установки проекта:

Obsoletes-Dist: OldName

Маркеры окружения

Маркер окружения является условным переходом, зависящим от окружения исполнения и добавляемым в конец поля после точки с запятой. Некоторые примеры приведены ниже:

Requires-Dist: pywin32 (>1.0); sys.platform == 'win32'
Obsoletes-Dist: pywin31; sys.platform == 'win32'
Requires-Dist: foo (1,!=1.3); platform.machine == 'i386'
Requires-Dist: bar; python_version == '2.4' or python_version == '2.5'
Requires-External: libxslt; 'linux' in sys.platform

Микроязык для маркеров окружения сознательно разработан с учетом простоты понимания разработчиками, не знакомыми с языком Python: он сравнивает строки с помощью операторов == и in (и противоположных) и позволяет использовать обычные логические комбинации. Поля из стандарта PEP 345, совместно с которыми могут использоваться эти маркеры:

14.4. Усовершенствованные стандарты

14.4.2. Что установлено?

Использование единого формата списков установленных файлов для всех инструментов Python необходимо для их взаимодействия. Если мы хотим, чтобы установщик A определял то, что установщик B установил ранее проект Foo, оба этих установщика должны использовать и обновлять одну и ту же базу данных установленных проектов.

Конечно же, в идеальном случае пользователи должны использовать один установщик в их системе, но им может понадобиться перейти к использованию нового установщика, предоставляющего специфические возможности. Например, в составе Mac OS X поставляется пакет Setuptools, поэтому пользователи автоматически получают в свое распоряжение сценарий easy_install. Если они пожелают перейти к использованию нового инструмента, им придется воспользоваться инструментом, который поддерживает обратную совместимость с предыдущим.

Другой проблемой является использование установщика Python на платформах, использующих системы управления пакетами, аналогичные RPM, так как в этом случае нет способа информировать систему об установке проекта. Вся сложность заключается в том, что хотя система управления пакетами Python и может каким-либо образом получить доступ к центральной системе управления пакетами, возникнет необходимость сопоставления метаданных Python с метаданными центральной системы управления пакетами. Название проекта, например, может быть различным для этих систем. Это может произойти по нескольким причинам. Наиболее известной причиной является конфликт названий: другой проект вне экосистемы Python может использовать то же название в рамках системы управления пакетами RPM. Другой причиной является использование префикса python, которое нарушает соглашение, используемое при создании пакетов для платформы. Например, если название вашего проекта foo-python, велика вероятность, что пакет RPM в системе Fedora будет назван python-foo.

Одним из способов преодоления данной проблемы является глобальная установка Python с помощью центральной системы управления пакетами и работа в изолированном окружении. Такие инструменты, как Virtualenv могут помочь в этом случае.

В любом случае нам необходим единый формат установки пакетов в Python, так как другим системам установки пакетов также необходимо взаимодействовать с системой установки пакетов Python в тех случаях, когда пакеты Python устанавливаются с помощью них. Как только сторонняя система управления пакетами регистрирует установленный проект в своей базе данных, ей необходимо сгенерировать корректные метаданные установки для Python таким образом, чтобы проекты определялись как установленные установщиками Python и другими API, запрашивающими данные об установленных пакетах в Python.

Сопоставление метаданных актуально и в данном случае: так как система управления пакетами RPM обладает информацией о том, какие проекты Python устанавливаются с помощью нее, она может генерировать корректные метаданные уровня Python. Например, данной системе известно, что названию python26-webob соответствует название WebOb в экосистеме PyPI.

Вернемся к нашему стандарту: стандарт PEP 376 описывает формат устанавливаемых пакетов, аналогичный используемым форматам проектов Setuptools и Pip. Описанная структура представлена директорией с расширением dst-info, которая содержит файлы:

Как только все существующие инструменты будут поддерживать этот формат, у нас будет возможность управлять проектами Python вне зависимости от определенного установщика и его возможностей. Также, поскольку стандарт PEP 376 описывает хранилище метаданных в виде директории, расширение метаданных сводится к добавлению новых файлов. На самом деле, новый файл метаданных с именем RESOURCES, описанный в следующем разделе, может быть добавлен в ближайшем будущем без внесения изменений в стандарт PEP 376. В конечном счете, если этот новый файл окажется полезным для всех инструментов, его описание будет добавлено в стандарт PEP.

14.4. Усовершенствованные стандарты

14.4.3. Архитектурные решения в отношении работы с файлами данных

Как описывалось ранее, нам необходимо предоставить возможность принятия решений о местах размещения файлов данных ответственным за создание пакетов лицам, при этом их действия не должны приводить к неработоспособности кода. В то же время разработчик должен иметь возможность работы с файлами данных, не беспокоясь о их размещении. Наше решение является обычным применением обходных путей.

Использование файлов данных

Предположим, что вашему приложению MPTools требуется использовать конфигурационный файл. Разработчик должен поместить этот файл в пакет Python и использовать переменную __file__ для доступа к нему:

import os

here = os.path.dirname(__file__)
cfg = open(os.path.join(here, 'config', 'mopy.cfg'))

Подразумевается, что конфигурационные файлы устанавливаются аналогично файлам с кодом и разработчик обязан разместить эти файлы вместе с кодом: в этом примере в поддиректории с именем config.

Новые архитектурные решения в отношении работы с файлами данных позволяют использовать дерево директорий проекта в качестве корневой директории для всех файлов, а также позволяют получать доступ к любому файлу в этом дереве независимо от того, расположен ли он в пакете Pyhon или в простой директории. Эти решения позволили разработчикам создавать отдельную директорию для файлов данных и получать доступ к ним с помощью метода pkgutil.open:

import os
import pkgutil

# Открыть файл, расположенный в директории config/mopy.cfg проекта MPTools
cfg = pkgutil.open('MPTools', 'config/mopy.cfg')

Метод pkgutil.open получает доступ к метаданным проекта и проверяет наличие файла RESOURCES. В этом файле находится простой список соответствия для имен файлов и возможных мест их расположения в системе:

config/mopy.cfg {confdir}/{distribution.name}

В данном случае переменная {confdir} указывает на системную директорию для файлов конфигурации, а переменная {distribution.name} содержит имя проекта Python, извлеченное из метаданных.

Поиск файла
Рисунок 14.4: Поиск файла

Как только данный файл метаданных RESOURCES создается во время установки, у разработчика появляется возможность с помощью API узнать о расположении файла mopy.cfg. И так как путь config/mopy.cfg относится к дереву директорий проекта, появляется возможность для реализации режима разработчика, в котором метаданные для проекта генерируются в директории проекта и путь к ним добавляется в список директорий поиска pkgutil.

Объявление файлов данных

На практике проект может установить место расположения файлов данных, объявив сопоставление в файле setup.cfg. Сопоставление является списком кортежей формата (шаблон в формате glob, целевой путь). Каждый шаблон соответствует одному или нескольким файлам в дереве проекта, а целевой путь указывает путь для установки и может содержать переменные в фигурных скобках. Например, файл setup.cfg проекта MPTools может выглядеть подобным образом:

[files]
resources =
        config/mopy.cfg {confdir}/{application.name}/
        images/*.jpg    {datadir}/{application.name}/

В модуле sysconfig объявлен и документирован список специальных переменных, которые могут быть использованы, а также их значения по умолчанию для каждой из платформ. Например, значением переменной {confdir} является строка /etc в Linux. Таким образом, установщики могут использовать это сопоставление вместе с модулем sysconfig во время установки для получения путей для копирования файлов. В конечном счете, установщики будут генерировать упоминавшийся ранее файл RESOURCES в составе метаданных и, таким образом, расположение этих файлов сможет быть установлено впоследствии с помощью функций модуля pkgutil.

Установщик
Рисунок 14.5: Установщик

14.4. Усовершенствованные стандарты

14.4.4. Усовершенствования каталога PyPI

Как я говорил ранее, каталог PyPI может оказаться единой точкой отказа. Стандарт PEP 380 направлен на устранение данной проблемы и описывает протокол зеркалирования, позволяющий пользователям обращаться к альтернативным серверам в случае неработоспособности центрального сервера PyPI. Целью данных усовершенствований является предоставление возможности членам сообщества вводить в строй зеркальные серверы по всему миру.

Зеркалирование
Рисунок 14.6: Зеркалирование

Список зеркал формируется в виде списка имен узлов в форме X.pypi.python.org, где X является последовательностью буквенных символов a,b,c,...,aa,ab,.... Сервер с именем a.pypi.python.org является мастер-сервером, а имена зеркальных серверов начинаются с символа b. Запись CNAME last.pypi.python.org указывает на имя последнего узла, поэтому клиенты, использующие PyPI, могут составить список зеркальных серверов, получив запись CNAME.

Например, данный вызов сообщает пользователю о том, что последним зеркальным сервером является сервер с именем h.pypi.python.org, а это значит, что каталог PyPI на данный момент использует 6 серверов зеркал (обозначенных символами от b до h):

>>> import socket
>>> socket.gethostbyname_ex('last.pypi.python.org')[0]
'h.pypi.python.org'

Теоретически данный протокол позволяет клиентам осуществлять переадресацию запросов на ближайший к ним зеркальный сервер, устанавливая расположение серверов на основе их IP-адресов, а также переходить к другому зеркальному серверу в случае неработоспособности зеркального сервера или мастер-сервера. Протокол зеркалирования сам по себе является более сложным, чем простой протокол rsync, так как мы хотели получать точную статистику скачиваний и поддерживать минимальные меры безопасности.

Синхронизация

Зеркальные сервера должны снижать объем данных, передаваемых между центральным и зеркальным сервером. Для достижения этой цели они должны использовать вызов changelog интерфейса XML-RPC PyPI и обновлять только те пакеты, содержимое которых было изменено после последней проверки. Для каждого пакета P зеркальными серверами должны быть скопированы документы из директорий /simple/P/ и /serversig/P.

Если пакет был удален на центральном сервере, на зеркальных серверах этот пакет также должен быть удален вместе со всеми ассоциированными файлами. Для определения модификации файлов пакетов зеркальные сервера могут кэшировать параметр ETag для каждого файла и запрашивать файл с использованием заголовка If-None-Match, не принимая сам файл. Как только синхронизация завершается, зеркальный сервер помещает текущую дату в файл /last-modified.

Распространение статистических данных

Когда вы скачиваете релиз с одного из зеркал, информацию о скачивании передается мастер-серверу PyPI и другим зеркальным серверам по соответствующему протоколу. Этот принцип позволяет быть уверенным в том, что люди или инструменты, просматривающие каталог PyPI в поисках количества скачиваний релиза, получат значение, просуммированное по всем зеркальным серверам.

Статистические данные обрабатываются и заносятся файлы формата CSV со статистикой за день и за неделю, расположенные в директории stats на центральном сервере PyPI. Каждый зеркальный сервер должен использовать директорию local-stats для хранения своей собственной статистики. Каждый файл содержит данные о количестве скачиваний для каждого архива, сгруппированные по используемым заголовкам "User-Agent". Центральный сервер ежедневно посещает зеркальные сервера для сбора их статистических данных и добавления их в файлы из глобальной директории stats, поэтому каждый зеркальный сервер должен обновлять содержимое директории /local-stats как минимум раз в день.

Подлинность зеркальных серверов

В распределенной системе зеркальных серверов клиентам может понадобиться выяснить, являются ли копии данных подлинными. Возможные опасности включают в себя:

Для установления факта реализации первого типа атаки авторы пакетов должны подписывать свои пакеты с помощью ключей PGP, позволяя таким образом пользователям проверять, выпущен ли пакет автором, которому они доверяют. Протокол зеркалирования сам по себе предусматривает меры борьбы только со второй угрозой, хотя в нем и предпринята попытка определения факта реализации атак перехвата данных.

На центральном сервере в директории /serverkey хранится ключ DSA в формате PEM, сгенерированный с помощью команды openssl dsa -pubout3. Данная директория не должна зеркалироваться и клиенты должны получать официальный ключ напрямую из каталога PyPI или использовать его копию, предоставляемую в комплекте с клиентскими приложениями PyPI. Зеркальные сервера все же должны скачивать ключ для того, чтобы определять факт продления его срока действия.

Для каждого пакета зеркалируемая подпись находится в директории /serversig/package. Это подпись DSA для параллельной страницы с URL /simple/package в форме DER с использованием алгоритма SHA-1 с DSA4.

Клиенты, использующие зеркальные сервера, должны выполнить следующие действия для проверки пакета:

  1. Скачать страницу /simple и рассчитать ее хэш с помощью алгоритма SHA-1.
  2. Сформировать подпись DSA на основе данного хэша.
  3. Скачать соответствующий файл /serversig и побайтово сравнить его с подписью, сформированной на шаге 2.
  4. Рассчитать и проверить (сравнив со страницей /simple) хэши MD5 для всех скачиваемых с зеркального сервера файлов.

При скачивании файлов с центрального сервера проверка не требуется и клиенты должны отказаться от ее выполнения с целью снижения нагрузки.

Примерно раз в год ключ меняется на новый. Зеркальные серверы должны повторно получить все страницы /serversig после смены ключа. Клиенты, использующие зеркальные серверы, должны получить заслуживающую доверия копию ключа сервера. Одним из способов получения ключа является его загрузка с ресурса https://pypi.python.org/serverkey. Для установления факта реализации атак перехвата трафика клиенты должны проверять SSL-сертификат сервера, который должен быть подписан центром CACert.

14.5. Подробности реализации

Реализация большинства описанных в предыдущих разделах улучшений была проведена в рамках проекта Distutils2. Файл setup.py больше не используется, а проект полностью описывается с помощью файла setup.cfg, являющегося статическим .ini-подобным файлом. Таким образом мы упростили работу лиц, осуществляющих упаковку проектов, позволив изменять ход установки проекта без вмешательства в код на языке Python. Ниже приведен пример такого файла:

[metadata]
name = MPTools
version = 0.1
author = Tarek Ziade
author-email = tarek@mozilla.com
summary = Set of tools to build Mozilla Services apps
description-file = README
home-page = http://bitbucket.org/tarek/pypi2rpm
project-url: Repository, http://hg.mozilla.org/services/server-devtools
classifier = Development Status :: 3 - Alpha
    License :: OSI Approved :: Mozilla Public License 1.1 (MPL 1.1)

[files]
packages =
        mopytools
        mopytools.tests

extra_files =
        setup.py
        README
        build.py
        _build.py

resources =
    etc/mopytools.cfg {confdir}/mopytools

Пакет Distutils2 использует файл конфигурации для:

В рамках проекта Distutils2 также реализован механизм схем версий VERSION с помощью модуля version.

Реализация механизма создания списка установленных файлов INSTALL-DB будет добавлена в стандартную библиотеку Python версии 3.3 в рамках модуля pkgutil. В промежуток времени до добавления в стандартную библиотеку версия этого модуля существует в проекте Distutils2 и доступна для непосредственного использования. Предоставляемые API позволяют просматривать установки проектов и получать списки установленных файлов.

Эти API являются базисом для некоторых замечательных возможностей, предоставляемых Distutils2:

14.6. Выученные уроки

14.6.1. О стандартах PEP

Изменение архитектуры таких обширных и сложных проектов, как система управления пакетами Python, должно производиться аккуратно путем изменения стандартов PEP. А само изменение или добавление нового стандарта PEP, по моему опыту, занимает около года.

Одной ошибкой, которую совершило сообщество является разработка инструментов, исправляющих некоторые недоработки путем расширения метаданных и изменения хода установки приложений Python, без попытки изменения затронутых стандартов PEP.

Другими словами, в зависимости от используемого вами инструмента, Distutils из стандартной библиотеки или Setuptools, приложения устанавливались по-разному. Проблемы были преодолены одной частью сообщества, использующей новые инструменты, но для всего остального мира проблем только добавилось. Лица, ответственные за создание пакетов для операционных систем, столкнулись с несколькими стандартами Python: официальным документированным стандартом и фактически используемым стандартом, установленным разработчиками пакета Setuptools.

Но между тем, команда разработчиков пакета Setuptools имела возможность проведения экспериментов над огромной аудиторией (всем сообществом), быстро внедряя инновации, поэтому информация от пользователей была бесценна. У нас же была возможность разрабатывать новые стандарты PEP с большей уверенностью в том, что будет работать, а что не будет и, возможно, по-другому этого сделать бы и не удалось. Таким образом, процесс заключался в наблюдении за тем, какие инновации предлагались сторонними инструментами и решали ли эти инновации проблемы достаточно хорошо, чтобы инициировать изменение стандарта PEP.

14.6.2. Пакет, добавленный в стандартную библиотеку, находится одной ногой в могиле

Я перефразировал Гвидо ван Россума в названии раздела, но существует один аспект философии "batteries-included" Python, который значительно повлиял на нашу работу.

Пакет Distutils является частью стандартной библиотеки и пакет Distutils2 также скоро станет ее частью. Пакет, являющийся частью стандартной библиотеки, очень сложно развивать. Конечно же, существуют процессы по удалению устаревшего кода, в ходе которых вы можете удалить или изменить часть API после 2 подверсий Python. Но как только происходит публикация API, данный интерфейс остается неизменным в течении многих лет.

Таким образом, любое сделанное вами изменение в пакете стандартной библиотеки, не являющееся исправлением ошибки, является потенциальным нарушением целостности экосистемы. Поэтому тогда, когда вы делаете важные изменения, вы должны создавать новый пакет.

Я убедился в этом на собственном опыте, когда в процессе работы над пакетом Distutils мне пришлось убрать все изменения, сделанные мною более чем за год, и создать пакет Distutils2. В будущем, если наши стандарты снова изменятся кардинальным образом, велика вероятность того, что мы с самого начала начнем работу над отдельным проектом Distutils3, конечно же, если в стандартной библиотеке не появится другой установщик.

14.6.3. Обратная совместимость

Изменение принципа работы системы управления пакетами в Python является очень долгим процессом: экосистема Python содержит множество проектов, базирующихся на устаревших инструментах управления пакетами, поэтому сопротивление изменениям будет весьма значительным. (Достижение соглашения по вопросам, описанным в данной главе книги, заняло несколько лет вместо нескольких месяцев, как я ожидал сначала.) Как и в случае с Python 3, пройдут годы перед тем, как все проекты перейдут на использование нового стандарта.

Именно поэтому все разрабатываемые нами программные продукты должны поддерживать обратную совместимость с используемыми ранее инструментами, установками и стандартами, что делает реализацию пакета Distutils2 еще более сложной.

Например, если проект, использующий новые стандарты, зависит от другого проекта, который их пока еще не использует, мы не можем прерывать процесс установки, сообщая пользователю о том, что пакет, от которого зависит проект, использует неизвестный формат!

Стоит отметить, что реализация механизма INSTALL-DB содержит код для совместимости, позволяющий работать с проектами, установленными с помощью оригинального пакета Distutils, Pip, Distribute или Setuptools. Distutils2 также поддерживает возможность установки проектов, созданных с использованием оригинального пакета Distutils, конвертируя метаданные в ходе установки.

14.7. Справочные материалы и вклад сообщества

Некоторые разделы этой главы были взяты напрямую из различных документов PEP, разработанных нами для стандартизации процесса управления пакетами. Вы можете найти оригинальные документы на сайте http://python.org:

Я хотел бы поблагодарить всех людей, которые работали над системой управления пакетами; вы можете найти их имена в любом из документов PEP, о которых я упоминал. Я также хотел бы особо поблагодарить всех участников Товарищества Разработчиков Системы Пакетов. Также благодарю Alexis Metaireau, Toshio Kuratomi, Holger Krekel и Stefane Fermigier за их отзывы к данной главе.

Проекты, которые мы обсуждали в данной главе:

Сноски

  1. Предложения по изменению в Python (The Python Enhancement Proposals - PEP), на которые мы ссылаемся, описаны в конце этой главы.
  2. Ранее известный как CheeseShop.
  3. Т.е. RFC 3280 SubjectPublicKeyInfo с алгоритмом 1.3.14.3.2.12.
  4. Т.е. как RFC 3279 Dsa-Sig-Value, на основе алгоритма 1.2.840.10040.4.3.

На главную -> MyLDP -> Тематический каталог ->

Riak и Erlang/OTP

Глава 15 из книги "Архитектура приложений с открытым исходным кодом", том 1.

Оригинал: "Riak and Erlang/OTP", глава из книги "The Architecture of Open Source Applications"
Авторы: Francesco Cesarini, Andy Gross, and Justin Sheehy
Дата публикации: 2012 г.
Перевод: Н.Ромоданов
Дата перевода: апрель 2013 г.

Creative Commons: Перевод был сделан в соответствие с лицензией Creative Commons. С русским вариантом лицензии можно ознакомиться здесь.

Riak является распределенной отказоустойчивой СУБД с открытым исходным кодом, на которой проиллюстрировано, как с помощью среды Erlang/OTP создавать крупномасштабные системы. Во многом благодаря тому, что в языке Erlang поддерживается работа с масштабируемыми распределенными системами, в Riak предлагаются функции, которые весьма редки в базах данных, например, высокая готовность и линейная масштабируемость, причем как по емкости базы, так и по ее пропускной способности.

Erlang/OTP является идеальной платформой для разработки таких систем, как Riak, поскольку в ней сразу «из коробки» предлагаются средства взаимодействия между узлами, очереди сообщений, детекторы отказов и клиент-серверные абстракции. Более того, наиболее часто используемые в языке Erlang шаблоны реализованы в виде библиотечных модулей, которые обычно имеют в виду, когда говорят о поведениях среды OTP (OTP behaviors). В них содержится основной фреймворк кода, предназначенный для параллельной работы и обработки ошибок, что упрощает параллельное программирование и защищает разработчика от многих распространенных ошибок. Поведения контролируются супервизорами, самим поведением, и все они сгруппированы в виде дерева мониторинга. Дерево мониторинга упаковывается в приложение, представляющее собой строительный блок программы на языке Erlang.

Полная система Erlang, например, Riak представляет собой набор слабо связанных приложений, которые взаимодействуют друг с другом. Некоторые из этих приложений пишутся разработчиком, некоторые из них являются частью стандартного дистрибутива Erlang/OTP, а некоторые могут быть другими компонентами, имеющими открытый исходный код. Они последовательно загружаются и запускаются с помощью загрузочного скрипта, созданного из списка приложений и версий.

Системы различаются своими приложениями, которые являются частью запускаемого релиза. В стандартном дистрибутиве Erlang загрузочные файлы будут запускать ядро Kernel и StdLib (стандартная библиотека). В некоторых инсталляциях также запускается приложение SASL (Systems Architecture Support Library — Библиотека поддержки архитектуры систем). В SASL содержится релиз и инструментарий обновления программ, а также средства регистрации событий. Riak ничем не отличается, кроме запуска конкретных для Riak приложений, а также их зависимостей времени выполнения, к числу которых относятся Kernel, StdLib и SASL. Эти стандартные элементы дистрибутива Erlang/OTP входят, на самом деле, в состав полной и готовой для запуска сборки Riak, и, когда из командной строки вызывается команда riak start, они все запускаются в унисон. Riak состоит из многих сложных приложений, поэтому эту главу не нужно рассматривать в качестве полного руководства. Ее следует рассматривать как введение в OTP, в котором в качестве примеров используется исходный код Riak. Для удобства демонстрации рисунки и примеры кода были упрощены и сокращены.

15.1. Краткое введение в язык Erlang

Язык Erlang является функциональным языком параллельного программирования, который компилируется в байт-код и работает в виртуальной машине. Программы состоят из функций, которые вызывают друг друга и часто возвращают результаты в виде межпроцессорных сообщений, представляющих собой побочный эффект, а также выполняют операции ввода вывода и обращаются к базе данных. Значения переменным языка Erlang присваиваются только один раз, то есть, если им было присвоено значение, оно не может быть изменено. В языке широко используется сравнение с шаблонами так, как это показано ниже на примере расчета факториала:

-module(factorial).
-export([fac/1]).
fac(0) -> 1;
fac(N) when N>0 ->
   Prev = fac(N-1),
   N*Prev.

Здесь первое предложение (clause) выдает факториал нуля, второе — выдает факториал положительных чисел. Тело каждого оператора представляет собой последовательность выражений, и последнее выражение в теле является результатом этого предложения. Вызов функции с отрицательным числом приведет к ошибке времени выполнения, поскольку предложение сравнения для такого случая отсутствует. Отсутствие обработки этого случая является примером небезопасного программирования, практика которого поощряется в языке Erlang.

Внутри модуля обращение к функции осуществляется обычным образом; вне модуля — сначала добавляется имя модуля, например, factorial:fac(3). Можно определять функции с тем же самым именем, но различным числом аргументов, это называется их арностью. В директиве export в модуле factorial арность функция fac равна единице, что обозначено как fac/1.

В языке Erlang поддерживается работа с кортежами (также называемыми продукционными типами), а также со списками. Кортежи заключаются в фигурные скобки, например, {ok,37}. В кортежах мы получаем доступ к элементам по их позиции. Записи являются другим типом данных, они позволяют нам хранить фиксированное количество элементов, доступ к которым и работа с ним осуществляется по имени. Мы определяем запись при помощи директивы -record(state, {id, msg_list=[]}). Чтобы создать экземпляр, мы используем выражение Var = #state{id=1}, и мы проверяем его содержимое при помощи Var#state.id. Если число элементов переменное, мы используем списки, которые записываются в квадратных скобках, например, {[}23,34{]}. Нотация {[}X|Xs{]} соответствует непустому списком с головой X и хвостом Xs. Идентификаторы, начинающиеся с маленькой буквы, обозначают атомы, которые просто представляют самих себя; ok в кортеже {ok,37} является примером атома. Атомы, используемые таким образом, часто применяются для указания различных видов результата функции: точно также как результаты ok, могут быть результаты вида {error, "Error String"}.

Процессы в системах Erlang работают параллельно в отдельно выделяемых областях памяти и взаимодействуют между собой посредством передачи сообщений. Процессы могут использоваться в приложениях для всего, в том числе в виде шлюзов к базам данных, обработчиков стеков протоколов, а также для управления средствами регистрации сообщений трассировки других процессов. Хотя эти процессы обрабатывают различные виды запросов, они аналогичны в том, как эти запросы обрабатываются.

Поскольку процессы существуют только в виртуальной машине, одна виртуальная машина может одновременно выполнять миллионы процессов — это та особенность Riak, которой широко пользуются. Например, каждый запрос к базе данных — чтение, запись и удаление - моделируется как отдельный процесс, это тот подход, который в большинстве случаев нельзя реализовать с помощью работы с потоками на уровне ОС.

Процессы идентифицируется при помощи идентификаторов процессов, которые называются PID, но их также можно зарегистрировать с использованием алиаса; этим следует пользоваться только для долгоживущих "статических" процессов. Регистрация процесса с использованием алиаса позволяет другим процессам посылать ему сообщений, не зная его идентификатора PID. Процессы создаются с помощью встроенной функции spawn(Module, Function, Arguments)(built-in function -BIF). Функции BIF интегрированы в виртуальную машину и используются для того, чтобы делать то, что в чистом языке Erlang сделать нельзя или что делается слишком медленно. Встроенная функция spawn/3 получает в качестве параметров модуль Module, функцию Function и список аргументов Arguments. Вызов возвращает идентификатор PID только что порожденного процесса и, в качестве побочного эффекта, создает новый процесс, в котором начинается выполнение функции в модуле с аргументами, упомянутыми ранее.

Сообщение Msg отправляется в процесс с идентификатором Pid при помощи конструкции Pid ! Msg. Идентификатор PID процесса можно узнать, вызвав встроенную функцию self, и затем его можно отослать в другие процессы для того, чтобы они использовали его для обмена сообщениями с исходным процессом. Предположим, что процесс ожидает получения сообщений вида {ok, N} и {error, Reason}. Чтобы их обработать, используется следующая инструкция приема сообщений:

receive
   {ok, N} ->
      N+1;
   {error, _} ->
      0
end

Результатом работы этой инструкции является число, определяемое в предложении, в котором происходит сравнения с образцом. Если в сравнении с образцом не требуется определять значение переменной, то можно использовать универсальный символ подчеркивания так, как это показано выше.

Передача сообщений между процессами является асинхронной, и сообщения, принимаемые процессом, помещаются в почтовый ящик процесса в том порядке, в котором они поступают. Теперь предположим, что выражение receive, приведенное выше, выполняется: если первый элемент в почтовом ящике будет {ok, N} или {error, Reason}, то будет возвращен соответствующий результат. Если первое сообщение в почтовом ящике имеет другой вид, то оно сохраняется в почтовом ящике, и таким же самым образом обрабатывается второе сообщение. Если нет совпадающих сообщений, то выражение receive будет ждать до тех пор, пока не поступит совпадающее сообщение.

Процессы завершаются по двум причинам. Если для выполнения больше нет кода, то говорят, что процесс завершается по причине normal (нормальное завершение). Если в процессе во время выполнения возникают ошибки, то говорят, что он завершается по причине non-normal (ненормальное завершение). Завершение процесса не влияет на другие процессы, если они с ним не скомпонованы. Процессы можно скомпоновать друг с другом с помощью встроенной функции link(Pid) или при вызове spawn_link(Module, Function, Arguments). Если процесс завершается, то он посылает сигнал выхода EXIT в процессы, с которыми он скомпонован. Если происходит ненормальное завершение, то процесс завершается самостоятельно перенаправляя дальше сигнал EXIT. Если вызвать встроенную функцию process_flag(trap_exit, true), то вместе завершения процессы могут получить в своих почтовых ящиках сигналы EXIT, представляющие собой сообщения языка Erlang.

В Riak сигналы EXIT используются для мониторинга нормального состояния вспомогательных процессов обработки некритических операций, инициированных по запросам конечных автоматов. Если эти вспомогательные процессы завершаются ненормально, сигнал EXIT позволяет родительскому процессу либо игнорировать ошибку, либо перезапустить процесс.

15.2. Остов процесса

Ранее мы определили, что процессы создаются по общему образцу независимо от того, для какой цели они были созданы. Для начала, должен быть создан процесс, а затем, при необходимости, должен быть зарегистрирован алиас. Первым действием вновь созданного процесса является инициализации данных цикла процесса. Данные цикла часто создаются в результате передачи аргументов при инициализации процесса во встроенную функцию spawn. Данные цикла процесса сохраняются в переменной, которую мы называем состоянием процесса. Состояние, часто сохраняемое в виде записи, передается в функцию приема-оценки, работающую в цикле, который получает сообщение, обрабатывает его, обновляет состояние, и передает его обратно в качестве аргумента в вызов оставшейся части рекурсивного обработки. Если одно из сообщений, которые он обрабатывает, является сообщением «stop», то процесс, принявший его, очистит все за собой, а затем завершится.

Это повторяющаяся тема, которая будет происходить с процессами независимо от задачи, выполняемой процессом. Помня об этом, давайте посмотрим на различия между процессами, соответствующими этому образцу:

Поэтому, даже если и есть скелет общей последовательности действий, эти действия дополняются другими действиями, которые непосредственно связаны с конкретными задачами, выполняемыми в этом процессе. Используя этот скелет в качестве шаблона, программисты могут создавать процессы языка Erlang, которые действуют как серверы, конечные автоматы, обработчики событий и супервизоры. Но для того, чтобы каждый раз не реализовывать повторно эти образцы, они были помещены в библиотечные модули, которые называются поведением (behavior). Они поставляются как часть промежуточной среды OTP.

15.3. Поведения OTP

Основной костях команды разработчиков Riak разбросан почти по десятку различных географических точек. Без очень жесткой координации и шаблонов, по которым должна строиться работа, результат мог бы представлять собой разнообразные клиент/серверные реализации, в которых не обрабатывались специальные пограничные случаи и которые бы не справились с ошибками, касающихся параллельной обработки. Вероятно, нет единого способа работы с клиент/серверными сбоями, и нет гарантии, что ответ на запрос, действительно является ответом, а не просто некоторым сообщением, не противоречащим внутреннему протоколу передачи сообщений.

OTP представляет собой набор библиотек Erlang и принципов проектирования, предлагаемых в виде набора готовых к применению инструментальных средств, предназначенных для разработки надежных систем. Многие из этих образцов и библиотек предоставлены в виде «поведений» («behaviors»).

Поведения OTP решают эти вопросы путем предоставления библиотечных модулей, реализующих наиболее распространенные шаблонов параллельного проектирования. В самой библиотеке в фоновом режиме реализуется, причем так, что программист об этом может не знать, постоянная обработка ошибок и специальных случаев. В результате, поведения OTP предоставляют набор стандартных блоков, используемых при проектировании и создании систем промышленного класса.

15.3.1. Введение

Поведения OTP представлены в виде библиотечных модулей в приложении stdlib, которое поставляется как часть дистрибутива Erlang/OTP. Конкретный код, написанный программистом, помещается в отдельный модуль и вызывается через набор стандартных предопределенных функций обратного вызова, которые стандартизированы для каждого поведения. Такой модуль обратного вызова будет содержать весь конкретный код, необходимый для достижения требуемой функциональности.

Поведения OTP включают в себя рабочие процессы (worker process), которые выполняют фактическую обработку, и супервизоры (supervisor), задачей которых является наблюдение за рабочими процессами и другими супервизорами. Поведения рабочих процессов, часто обозначаемые в диаграммах в виде кружков, включают в себя серверы, обработчики событий и конечные автоматы. Супервизоры, обозначаемые на иллюстрациях в виде прямоугольников, следят за дочерними элементами, причем как за рабочими процессами, так и за другими супервизорами, создавая на то, что называется деревом мониторинга.

Рис.15.1: Дерево мониторинга OTP Riak

Деревья мониторинга упакованы в поведение, которое называется приложением. Приложения OTP не только являются строительными блоками систем Erlang, но также и способом упаковки повторно используемых компонентов. Системы промышленного уровня, такие как Riak, состоят из множества слабо связанных, возможно, распределенных приложений. Некоторые из этих приложений являются частью стандартного дистрибутива Erlang, а некоторые являются теми частями, в которых реализована конкретная функциональность Riak.

К числу примеров приложений OTP относятся CORBA ORB или агент Simple Network Management Protocol (SNMP). Приложение OTP является повторно используемым компонентом, в котором упакованы библиотечные модули вместе с супервизорами и рабочими процессами. Теперь, когда мы говорим о приложении, мы будем подразумевать приложение OTP.

Модули поведения содержат весь общий код для каждого конкретного типа поведения. Хотя можно реализовать свой собственный модуль поведения, делается это редко, поскольку те модули, которые приходят с дистрибутивом Erlang/OTP, обычно содержат большую часть проектных шаблонов, которые вам может потребоваться использовать в своем коде. К числу общих функциональных возможностей, которые предоставлены в модуле поведения, относятся следующие операции:

Данные цикла являются переменной, в которой поведение будет хранить данные, необходимые ему между вызовами. После вызова будет возвращаться обновленный вариант данных цикла. Эти обновленные данные цикла, которые часто называются новыми данными цикла, передаются в качестве аргумента в следующий вызов. Данные цикла также часто называются состоянием поведения.

К числу функциональных возможностей, которые должны быть в модуле обратного вызова для общего сервера приложений с тем, чтобы в нем реализовать требуемое поведение, относятся следующие:

15.3.2 Основные серверы

Основные серверы, в которых реализовано поведение клиент/сервер, определены в поведении gen_server, которое поставляется как часть стандартного библиотечного приложения. При рассмотрении основных серверов, мы будем пользоваться модулем riak_core_node_watcher.erl из приложения riak_core. Это сервер, который отслеживает и сообщает о том, какие суб-сервисы и узлы имеются в кластере Riak. Заголовки и директивы модуля выглядят следующим образом:

-module(riak_core_node_watcher).
-behavior(gen_server).
%% API
-export([start_link/0,service_up/2,service_down/1,node_up/0,node_down/0,services/0,
         services/1,nodes/1,avsn/0]).
%% gen_server callbacks
-export([init/1,handle_call/3,handle_cast/2,handle_info/2,terminate/2, code_change/3]).

-record(state, {status=up, services=[], peers=[], avsn=0, bcast_tref,
                bcast_mod={gen_server, abcast}}).

Мы можем легко отличить общий сервер по директиве -behavior(gen_server). Эта директива используется компилятором для того, чтобы правильно экспортировать все необходимые функции обратного вызова. В данных цикла сервера используется информация о статусе записи.

15.3.3. Запуск вашего сервера

Когда используется поведение gen_server, вы вместо встроенных функций spawn и spawn_link будете пользоваться функциями gen_server:start и gen_server:start_link. Основным различием между spawn и start является синхронизированная природа выполнения вызова. Использование start вместо of spawn делает запуск рабочего процесса более детерминированным и предотвращает возникновение непредвиденных состояний гонки (race conditions), поскольку вызов не вернет идентификатор PID рабочего процесса до тех пор, пока процесс не будет инициализирован. Вы вызываете функции одним из следующих способов:

gen_server:start_link(ServerName, CallbackModule, Arguments, Options)
gen_server:start_link(CallbackModule, Arguments, Options)

ServerName является кортежем вида {local, Name} или {global, Name}, в котором указывается локальное или глобальное имя Name алиаса процесса для случая, если имя должно быть зарегистрировано. Глобальные имена позволяют серверам прозрачно обращаться в кластеры распределенных узлов Erlang. Если вы не хотите регистрировать процесс и вместо этого ссылаетесь на него по его идентификатору PID, вы не указываете имя и вместо этого пользуетесь функцией start_link/3 или start/3. CallbackModule является именем модуля, в котором размещены конкретные функции обратного вызова, Arguments является допустимым термином языка Erlang, который передается в функцию обратного вызова init/1, а Options является списком, с помощью которого вы сможете устанавливать флаги fullsweep_after и heapsize, а также другие флаги, используемые при трассировке и отладке.

В нашем примере, мы вызываем start_link/4 и с помощью макровызова ?MODULE, регистрируя процесс с тем же именем, что и у модуля обратного вызова. В этом макросе указывается имя модуля, которое будет определено препроцессором во время компиляции кода. Рекомендуется всегда задавать имя вашего поведения точно таким же, как и имя модуля обратного вызова, в котором оно реализовано. Мы не передаем никаких аргументов, и, в результате, просто отправляем пустой список. Список параметров остается пустым:

start_link() ->
    gen_server:start_link({local, ?MODULE}, ?MODULE, [], []).

Очевидным различием между функциями start_link и start является то, что start_link скомпонована со своим родителем, которым чаще всего оказывается супервизором, тогда как для start этого не происходит. Об этом необходимо особо упомянуть, поскольку поведение OTP само должно ссылаться на супервизор. Функции start часто используются при тестировании поведения внутри командной оболочки, т. к. ошибки ввода, из-за которых возникает сбой работы командной оболочки, не оказывают влияние на поведение. Все варианты функций start и start_link возвращают {ok, Pid}.

Функции start и start_link порождают новый процесс, в котором будет вызвана функция обратного вызова init(Arguments), находящаяся в модуле CallbackModule, с аргументами Arguments. Функция init должна инициализировать данные цикла сервера LoopData и должен возвращаться кортеж вида {ok, LoopData}. В данных LoopData содержится первый экземпляр цикла данных, которые будут передаваться между функциями обратного вызова. Если вы хотите сохранить некоторые из аргументов, передаваемые вами в функцию init, вы их также должны сохранять в переменной LoopData. Данные LoopData в сервере-наблюдателе узла Riak являются результатом работы функции schedule_broadcast/1, вызываемой с записью типа state, в которой значения, используемые по умолчанию, устанавливаются в полях этой записи следующим образом:

init([]) ->

    %% Watch for node up/down events
    net_kernel:monitor_nodes(true),

    %% Setup ETS table to track node status
    ets:new(?MODULE, [protected, named_table]),

    {ok, schedule_broadcast(#state{})}.

Хотя процесс супервизора может вызвать функция start_link/4, другой процесс вызывает функцию обратного вызова init/1: это тот процесс, который был только что создан. Поскольку назначение этого сервера обнаруживать, записывать и передавать всем сообщения о наличии внутри Riak подсервисов, во время инициализации делается запрос среде времени исполнения языка Erlang обнаруживать такие события и настраивается таблица, в которой запоминается эта информация. Это нужно делать во время инициализации, поскольку в случае, если этой структуры еще нет, любые обращения к серверу окончатся неудачей. В вашей функции init делайте только то, что необходимо, и минимизируйте количество операций, поскольку вызов функции init является синхронным вызовом, который не позволит запустить какой-нибудь другой процесс сериализации до тех пор, пока управление не будет возвращено из этой функции.

15.3.4. Передача сообщений

Если вы хотите отправить синхронное сообщение на ваш сервер, вы используете функцию gen_server:call/2. Асинхронные вызовы выполняются с помощью функции gen_server:cast/2. Давайте начнем рассмотрение с этих двух функций API, относящихся к сервисам приложения Riak; остальной код мы рассмотрим позже. Эти функции вызываются клиентским процессом и результатом является синхронное сообщение, посылаемое серверному процессу, зарегистрированному с тем же самым именем, что и модуль обратного вызова. Обратите внимание, что проверка данных, передаваемых на сервер, должна происходить на клиентской стороне. Если клиент посылает неверную информацию, сервер должен завершить свою работу.

service_up(Id, Pid) ->
    gen_server:call(?MODULE, {service_up, Id, Pid}).

service_down(Id) ->
    gen_server:call(?MODULE, {service_down, Id}).

После получения сообщения, процесс gen_server вызывает функцию обратного вызова handle_call/3, получающую сообщения в том же самом порядке, в котором они были отправлены:

handle_call({service_up, Id, Pid}, _From, State) ->
    %% Update the set of active services locally
    Services = ordsets:add_element(Id, State#state.services),
    S2 = State#state { services = Services },

    %% Remove any existing mrefs for this service
    delete_service_mref(Id),

    %% Setup a monitor for the Pid representing this service
    Mref = erlang:monitor(process, Pid),
    erlang:put(Mref, Id),
    erlang:put(Id, Mref),

    %% Update our local ETS table and broadcast
    S3 = local_update(S2),
    {reply, ok, update_avsn(S3)};

handle_call({service_down, Id}, _From, State) ->
    %% Update the set of active services locally
    Services = ordsets:del_element(Id, State#state.services),
    S2 = State#state { services = Services },

    %% Remove any existing mrefs for this service
    delete_service_mref(Id),

    %% Update local ETS table and broadcast
    S3 = local_update(S2),
    {reply, ok, update_avsn(S3)};

Обратите внимание на значение, возвращаемое функцией обратного вызова. В кортеже содержится управляющий атом reply, сообщающий общему коду gen_server о том, что второй элемент кортежа (который в обоих наших случаях является атомом ok) является ответом, отправляемым обратно к клиенту. Третий элемент кортежа является новым состоянием new State, которое, в новой итерации сервера, передается в качестве третьего аргумента в функцию handle_call/3; в обоих случаях оно здесь обновляется с целью отобразить новое состояние имеющихся сервисов. Аргумент _From является кортежем, содержащим уникальную ссылку на сообщение и идентификатор клиентского процесса. Кортеж, как целое, используется в библиотечных функциях, которые мы в этой главе рассматривать не будем. В большинстве случаев, вам он не понадобится.

Библиотечный модуль gen_server имеет ряд встроенных механизмов и средств защиты, действующими за кулисами. Если ваш клиент посылает синхронное сообщение на ваш сервер, и вы не получаете ответ в течение пяти секунд, то процесс выполнения функции call/2 завершается. Вы можете изменить это, использовав для этого gen_server:call(Name, Message, Timeout), где Timeout является значением, указываемым в миллисекундах, или атомом бесконечности infinity.

Механизм тайм-аута сначала был добавлен для того, чтобы предотвратить взаимной блокировки и гарантировать, что работа серверов, которые случайно вызовут друг друга, будет завершена после тайм-аута, заданного по умолчанию. Сообщение об аварийной ситуации будет зарегистрировано, и, можно надеяться, приведет к тому, что ошибка будет найдена и исправлена. При тайм-ауте в пять секунд большинство приложений будут работать так, как это необходимо, но при очень больших нагрузках, вам, возможно, придется более точно задавать это значение или, возможно, даже использовать значение бесконечности infinity; этот выбор зависит от приложения. Во всех фрагментах с критическим кодом в Erlang/OTP используется infinity. В различных местах в Riak используются различные значения тайм-аута: infinity обычно используется в случае связанных между собой частей кода, а значения Timeout устанавливаются с учетом значения параметра, передаваемого пользователем, там, где в клиентском коде в Riak передается информация о том, что у операции должна быть возможность использовать тайм-аут.

Другие защитные механизмы, применяемые в случае использования функции gen_server:call/2, требуются в случае отправки сообщения на несуществующий сервер и в случае, когда сбой сервера происходит раньше, чем он отправит свой ответ. В обоих случаях, вызывающий процесс будет завершен. В чистом языке Erlang отправка сообщения, для которого в принимающем предложении никогда не происходит совпадения с образцом, рассматривается как ошибка, которая может привести к утечке памяти. Чтобы смягчить эту ситуацию, в Riak применяются две различные стратегии, использующих предложения сравнения, в которых сравнение осуществляется «со всем». В местах, где сообщение может быть инициировано пользователем, сообщение, которое не прошло сравнение, может быть просто отброшено. В местах, где такое сообщение может поступать только изнутри Riak, оно представляет собой ошибку, и поэтому будет выдан рапорт о внутреннем сбое, вызванном ошибкой, и рабочий процесс, который получил это сообщение, будет перезапущен.

Отправка асинхронных сообщений работает аналогичным образом. Сообщения отправляются асинхронно в общий сервер и обрабатываются с помощью функция обратного вызова handle_cast/2. Функция должна возвращать кортеж вида {reply, NewState}. Асинхронные вызовы используются, когда нас не интересует запрос сервера и мы не беспокоимся о том, что отравляем больше сообщений, чем сервер может принять. В случаях, когда нас не интересует ответ, но мы хотим перед тем, как отослать следующее сообщение, подождать, пока не будет обработано первое сообщение, нам нужно использовать gen_server:call/2, которое в ответе возвратит атом ok. Представьте себе процесс создания записей базы данных, который происходит быстрее, чем может принять Riak. Если мы используем асинхронные вызовы, мы рискуем заполнить почтовый ящик процесса и создать в узле состояние нехватки памяти. Riak для регулировки нагрузки пользуется возможностью сериализации сообщений синхронных вызовов gen_server, при котором обработка следующего запроса происходит только после того, как был обработан предыдущий запрос. Такой подход устраняет необходимость в более сложных схемах управления кодом: в добавок тому, что процессы gen_server позволяют осуществлять параллельную обработку, их также можно использовать для выполнения сериализации.

15.3.5. Остановка сервера

Как остановить сервер? Вы можете в функциях обратного вызова вместо возврата кортежа {reply, Reply, NewState} или {noreply, NewState}, возвратить кортеж {stop, Reason, Reply, NewState} или {stop, Reason, NewState}, соответственно. Что-то должно быть причиной возврат такого значения — часто это сообщение об остановке, отправляемое на сервер. После получения кортежа, сообщающего об остановке и указывающего причину Reason и состояние State, общий код выполнит функцию обратного вызова terminate(Reason, State).

Функция terminate является обычно тем местом, куда вставляется код, необходимый для очистки состояния сервера State и любых других постоянно хранящихся данных, используемых системой. В нашем примере мы посылаем последнее сообщение всем нашим процессам, наблюдающим за этим узлом, с тем, чтобы они знали, что этот узел будет остановлен. В этом примере в переменной State содержится запись с полями состояний status и наблюдателей peers:

terminate(_Reason, State) ->
    %% Let our peers know that we are shutting down
    broadcast(State#state.peers, State#state { status = down }).

Использование функций обратного вызова поведения в качестве библиотечных функций и их вызов из других частей программы является очень плохим практическим примером. Например, для того, чтобы получить исходные данные цикла, вы никогда не должны вызывать riak_core_node_watcher:init(Args) из другого модуля. Такое получение данных должно осуществляется с помощью синхронного обращения к серверу. Обращения к функциям обратного вызова поведения должно происходить из библиотечных модулей только в случае событий, возникающих в самой системе, а не напрямую пользователем.

15.4. Другие поведения рабочих процессов

С помощью тех же самых идей можно реализовывать и были реализованы много других видов поведений рабочих процессов.

15.4.1. Автоматы конечных состояний

Конечные автоматы (или машины с конечным числом состояний - FSM), реализованные в модуле поведения gen_fsm, являются важнейшим компонентом реализации стеков протоколов, используемых в сетях связи (той проблемной области, для которой первоначально и был создан язык Erlang). Состояния определяются как функции обратного вызова, в названиях которых отражено то, что возвращается в кортеже, имеющем переменную следующего состояния State, и то, каким образом обновляются данные цикла. Вы можете отправлять события для этих состояний как синхронно, так и асинхронно. Модуль функции обратного вызова конечного автомата также должен иметь возможность экспортировать стандартные функции обратного вызова, например, init, terminate и handle_info.

Разумеется, конечные автоматы не предназначены исключительно для целей телекоммуникаций. В Riak, они используются в обработчиках запросов. Когда клиент выдает запрос, например, get, put или delete, процесс, услышавший этот запрос, создаст процесс, реализующий соответствующее поведение gen_fsm. Например, riak_kv_get_fsm отвечающий за обработку запросов get, извлекает данные и отправляет их клиентскому процессу. Процесс FSM будет проходить через различные состояния, поскольку в нем определено, из каких узлов запрашивать данные, как в эти узлы отправлять сообщения, и как в ответ принимать данные, ошибки или тайм-ауты.

15.4.2. Обработчики событий

Обработчики и менеджеры событий являются еще одним поведением, реализованным в библиотечном модуле gen_event. Идея состоит в том, чтобы создать централизованное место, где принимаются события определенного вида. События могут передаваться синхронно и асинхронно с заранее определенным набором действий, выполняемых при их получении. Возможными реакциями на события может быть запись их в файл, посылка сообщения об аварии в виде SMS или сбор статистических данных. Каждое из этих действий определяется в отдельном модуле обратного вызова с его собственными данными цикла, которые сохраняются между вызовами. Для каждого конкретного менеджера событий можно добавлять, удалять или обновлять обработчики событий. Таким образом, на практике, в каждом менеджере событий может быть много модулей обратного вызова, а в различных менеджерах событий также может быть много различных экземпляров таких модулей обратного вызова. К числу обработчиков событий относятся процессы, получающие сигналы тревоги, отслеживающие данные в реальном времени, следящие за событиями, связанными с оборудованием, или просто регистрирующие данные в журнале.

Одно из применений в Riak поведения gen_event является управление подписками в «кольце событий», т. е. изменений принадлежности узлам разделов или назначение разделов узлам в кластере Riak. Процессы в узле Riak могут регистрировать функцию в экземпляре событий riak_core_ring_events, реализующем поведение gen_event. Всякий раз, когда центральный процесс, управляющий кольцом событий в этом узле, изменяет запись о принадлежности, он создает событие, в результате которого в каждом из этих модулей обратного вызова будет вызвана зарегистрированная функция. Таким образом, можно достаточно просто организовать реагирование различных частей Riak на изменения в одной из самых центральных структур данных Riak, причем не добавляя сложности в сам механизм централизованного управления этой структурой.

Наиболее распространенные модели распараллеливания и взаимодействия процессов обрабатываются с помощью трех основных типов поведения, которые мы только что рассмотрели: gen_server, gen_fsm и gen_event. Тем не менее, в больших системах с течением времени возникает необходимость в некоторых моделях, предназначенных для конкретного приложения, , т. е. нужно создавать новые виды поведений. В Riak есть одно такое поведение, riak_core_vnode, в котором формализована реализация виртуальных узлов. Виртуальные узлы являются первичной абстракцией хранения данных в Riak, которая по запросам, управляемым с помощью конечных автоматов, предоставляет единый интерфейс хранилища данных вида «ключ - значение». Интерфейс модулей обратного вызова задается с помощью функции behavior_info/1 следующим образом:

behavior_info(callbacks) ->
    [{init,1},
     {handle_command,3},
     {handoff_starting,2},
     {handoff_cancelled,1},
     {handoff_finished,2},
     {handle_handoff_command,3},
     {handle_handoff_data,2},
     {encode_handoff_item,2},
     {is_empty,1},
     {terminate,2},
     {delete,1}];

В приведенном выше примере показана функция behavior_info/1 узла riak_core_vnode. В списке кортежей {CallbackFunction, Arity} определен контракт, которому должны следовать модули обратного вызова. Эти функции должны быть экспортированы в конкретные реализации виртуальных узлов, иначе компилятор выдаст предупреждение. Реализация своего собственного поведения OTP относительно проста. Кроме определений ваших собственных функций обратного вызова, использующих модули proc_lib и sys, вам потребуется запускать их вместе с конкретными функциями, обрабатывать системные сообщения и следить за тем, не завершился ли родительский процесс.

15.5. Супервизоры

Задачей поведения супервизора является отслеживание его потомков и, основываясь на некоторых предварительно заданных правилах, выполнение конкретный действий в случае, когда выполнение потомков завершается. В качестве потомков могут быть как супервизоры, так и рабочие процессы. Это позволило при разработке кода Riak сосредоточить внимание на его корректности, благодаря чему с помощью супервизоров можно во всей системе постоянно следить за ошибками в программном обеспечении, повреждениями данных или системными ошибками. В мире Erlang такой небезопасный подход к программированию часто называют стратегией «пускай выходит из строя». Среди потомков, за которыми наблюдает супервизор, могут быть как супервизоры, так и рабочие процессы. Рабочие процессы являются поведениями OTP, в том числе gen_fsm, gen_server и gen_event. Команда разработчиков Riak, у которой не было возможности обрабатывать пограничные ошибочные ситуации, ограничилась работой с кодом небольшого объема. Размер этого кода из-за того, что в нем используются поведения, гораздо меньшего размера, т. к. это код конкретного приложения. В Riak точно также как и в большинстве приложений Erlang, есть супервизор верхнего уровня, а также есть супервизоры следующих уровней для групп процессов соответствующего назначения. Примерами являются виртуальные узлы Riak, процессы, слушающие сокеты TCP, а также менеджеры запросов-ответов.

15.5.1. Функции обратного вызова супервизора

Чтобы продемонстрировать, как реализуется поведение супервизора, мы воспользуемся модулем riak_core_sup.erl. Супервизор ядра Riak является супервизором верхнего уровня приложения ядра Riak. Он запускает набор статических рабочих процессов и супервизоров, а также ряд динамических рабочих процессов, осуществляющих обработку привязок HTTP и HTTPS для интерфейса узлов RESTful API, определенного в конкретных конфигурационных файлах приложения. Точно также как и для gen_servers, во всех модулях обратного вызова супервизора должна быть директива -behavior(supervisor).. Модули запускаются при помощи функций start или start_link, в которых могут быть необязательные параметры ServerName, CallBackModule и Argument, передаваемые в функцию обратного вызова init/1.

Если взглянуть на несколько первых строк кода в модуле riak_core_sup.erl, то наряду с директивой поведения и макросом, о которых мы расскажем далее, мы обнаружим функцию start_link/3:

-module(riak_core_sup).
-behavior(supervisor).
%% API
-export([start_link/0]).
%% Supervisor callbacks
-export([init/1]).
-define(CHILD(I, Type), {I, {I, start_link, []}, permanent, 5000, Type, [I]}).
start_link() ->
    supervisor:start_link({local, ?MODULE}, ?MODULE, []).

В результате запуска супервизора будет порожден новый процесс и в модуле обратного вызова riak_core_sup.erl будет вызвана функция обратного вызова init/1. ServerName является кортежем в формате {local, Name} или {global, Name}, где Name является зарегистрированным именем супервизора. В нашем примере, как зарегистрированное имя, так и модуль обратного вызова являются атомом riak_core_sup, происходящим от макроса ?MODULE. Мы в качестве аргумента передаем в init/1 пустой список, который трактуется как значение null. Функция init является единственной функцией обратного вызова супервизора. Она должна возвращать кортеж следующего формата:

{ok,  {SupervisorSpecification, ChildSpecificationList}}

где SupervisorSpecification является трехэлементным кортежем {RestartStrategy, AllowedRestarts, MaxSeconds}, содержащим информацию о том, что делать в случае разроушения или перезапуска процесса. RestartStrategy является одним из трех конфигурационных параметров, определяющих какое влияние должно оказываться на потомков при аварийном завершении поведения:

AllowedRestarts указывает, сколько раз любой из потомков супервизора может завершиться в течение MaxSeconds секунд прежде, чем будет завершен сам супервизор (и его потомок). Когда происходит завершение, в супервизор посылается сигнал выхода EXIT, который, в соответствие с используемой стратегией перезапуска, обрабатывается определенным образом. Завершение супервизора после того, как будет достигнуто максимально допустимое количество перезагрузок, гарантирует, что не произойдет увеличение количества циклических перезагрузок и не возникнут другие проблемы, которые нельзя решить на этом уровне. Скорее всего, эта проблема, возникшая в процессе, будет локализована в отдельном поддереве, т. е. супервизор, получивший сообщение о распространении проблемы, завершит выполнение дерева, на которое проблема уже распространилась, и перезапустит его заново.

Если взглянуть на последнюю строку функции обратного вызова init/1 в модуле riak_core_sup.erl, то мы увидим, что в этом конкретном супервизоре используется стратегия one-for-one, означающая, что процессы не зависят друг от друга. Супервизор разрешит максимум десять раз выполнить перезапуск прежде, чем он сам будет перезапущен.

В списке ChildSpecificationList определяется, какие потомки должны запускаться и отслеживаться супервизором, а также указывается информация, как их завершать и перезапускать. Он представляет собой список кортежей следующего формата:

{Id, {Module, Function, Arguments}, Restart, Shutdown, Type, ModuleList}

Id является уникальным идентификатором для этого конкретного супервизора. {Module, Function, Arguments} является экспортируемой функцией, результаты работы которой будут использованы в поведении при вызове функции start_link и будет возвращен кортеж вида {ok, Pid}. В стратегии определяется, что в зависимости от того, как завершится процесс, должно происходить, а именно:

Shutdown является значением, указываемым в миллисекундах, которое используется в качестве времени, в течение которого поведению разрешается выполнять функцию terminate при ее перезапуске или при остановке системы. Можно также использовать атом бесконечного выполнения infinity, но это делать настоятельно не рекомендуется для поведений, не являющихся поведениями супервизора. Type является либо атомом рабочего процесса worker, если ссылка делается на общие серверы, обработчики событий и конечные автоматы, либо атомом supervisor. Вместе с ModuleList, списком модулей, реализующих поведение, они используются для управления процессами и их приостановкой во время выполнения процедуры обновления программного обеспечения. В списке спецификаций потомков можно указывать только поведения, которые уже существуют или реализованы пользователями и, следовательно, уже включены в дерево мониторинга.

Обладая этими знаниями, мы теперь сможем сформулировать стратегию перезапуска, в которой определены межпроцессные зависимости, пороги отказоустойчивости и ограничения эскалации работы процедур, базирующуюся на общей архитектуре. Мы также теперь должны суметь понять, что происходит в примере init/1 модуля riak_core_sup.erl. Прежде всего, изучим макрос CHILD. Он создает спецификацию потомка для одного потомка, используя для этого имя модуля обратного вызова, например, Id, делая его постоянно используемым и задавая для него время завершения, равное 5 секундам. Потомки могут быть различного типа — рабочие процессы и супервизоры. Давайте взглянем на пример и посмотрим, что из него можно выяснить:

-define(CHILD(I, Type), {I, {I, start_link, []}, permanent, 5000, Type, [I]}).

init([]) ->
    RiakWebs = case lists:flatten(riak_core_web:bindings(http),
                                  riak_core_web:bindings(https)) of
                   [] ->
                       %% check for old settings, in case app.config
                       %% was not updated
                       riak_core_web:old_binding();
                   Binding ->
                       Binding
               end,

    Children =
                 [?CHILD(riak_core_vnode_sup, supervisor),
                  ?CHILD(riak_core_handoff_manager, worker),
                  ?CHILD(riak_core_handoff_listener, worker),
                  ?CHILD(riak_core_ring_events, worker),
                  ?CHILD(riak_core_ring_manager, worker),
                  ?CHILD(riak_core_node_watcher_events, worker),
                  ?CHILD(riak_core_node_watcher, worker),
                  ?CHILD(riak_core_gossip, worker) |
                  RiakWebs
                 ],
    {ok, {{one_for_one, 10, 10}, Children}}.

Большинство потомков Children, запущенных этим супервизором, являются статически заданными рабочими процессами (или в случае vnode_sup, супервизором). Исключением является часть RiakWebs, которая определяется динамически в зависимости от части HTTP конфигурационного файла Riak.

За исключением библиотечных приложений, каждое приложение OTP, в том числе и те, что есть в Riak, будет иметь свое собственное дерево мониторинга. В Riak различные приложения верхнего уровня работают в узле Erlang, например, riak_core - алгоритмы для распределенных систем, riak_kv — семантики хранилищ вида «ключ/значение», webmachine - для HTTP, и многие другие. Мы показали расширенное дерево для riak_core с тем, чтобы продемонстрировать, как происходит многоуровневый мониторинг. Одним из многих преимуществ этой структуры является то, что когда данная подсистема может выйти из строя (из-за ошибки, проблемы со средой окружения или преднамеренного действия), то можно ограничиться завершением работы только для поддерева первого экземпляра.

Супервизор перезапустит необходимые процессы и система в целом затронута не будет. На практике мы видели как это работает в случае использования Riak. Пользователь может обнаружить, что произошло разрушение виртуального узла, что требует только перезапуска супервизора riak_core_vnode_sup. Если такое разрушение находится под контролем, то супервизор riak_core перезапустит нужный супервизор и предотвратит распространение остановок процессов супервизоров более высокого уровня. Такая изоляция отказов и механизм восстановления позволяет разработчикам Riak (и Erlang) достаточно просто создавать устойчивые системы.

Значение супервизорной модели была продемонстрирована, когда один крупный промышленный пользователь создал очень сложную среду эксплуатации с тем, чтобы выяснить, где каждая из нескольких систем баз данных будет разрушена. В этой среде были случайным образом сгенерированы огромные нагрузки, причем как по объему трафика, так и по наличию отказов. Каково было его смущение, когда Riak просто не остановил работу даже в таких непростых условиях. Конечно, если бы он заглянул внутрь, то обнаружил многочисленные остановки процессов или подсистем, но каждый раз супервизоры приводили все в порядок и заново запускали процессы и подсистемы, приводя, тем самым, всю систему обратно в рабочее состояние.

15.5.2. Приложения

Поведение application, которое мы ввели ранее, используется для упаковки модулей и ресурсов Erlang в виде компонентов, допускающих многократное использование. В OTP есть два вида приложений. Наиболее распространенная форма, называемая нормальными приложениями, запускает дерево мониторинга и все соответствующие статические рабочие процессы. В библиотечных приложениях, таких как стандартная библиотека Standard Library, которые поставляются как часть дистрибутива Erlang, содержатся библиотечные модули, но в них не происходит запуск дерева моноторинга. Это не значит, что в коде не может быть деревьев процессов или деревьев мониторинга. Это просто означает, что такие приложения запускаются как часть дерева мониторинга, принадлежащего другому приложению.

Система Erlang должна состоять из набора слабо связанных приложений. Некоторые из них пишут разработчики, некоторые из них доступны как проекты с открытым исходным кодом, а другие - являются частью дистрибутива Erlang/OTP. Система времени исполнения Erlang и его инструментальные средства работают со всеми приложениями одинаково независимо от того, являются ли те частью дистрибутива Erlang или нет.

15.6. Репликация и коммуникация в Riak

Riak был разработан для обеспечения высокой надежности и доступности при массовых нагрузках, причем на его разработку оказала влияние система хранения данных Dynamo компании Amazon [DHJ +07]. В архитектуре систем Dynamo and Riak объединены вместе особенности как распределенных хэш-таблиц DHT (Distributed Hash Tables), так и традиционных баз данных. Двумя ключевыми технологиями, которые используются в Riak и Dynamo, являются последовательное хеширование (consistent hashing), применяемое для размещения реплики, и протокол сплетен (gossip protocol), используемое для совместного доступа к общему состоянию.

Последовательное хеширование требует, чтобы все узлы системы знали друг о друге, и знали, каким разделом системы владеет каждый узел. Такие данные о назначениях можно хранить в централизованно управляемом конфигурационном файле, но для больших конфигураций это делать становится чрезвычайно трудно. Другой альтернативой является использование центрального конфигурационного сервера, но в результате в системе появляется место, из-за проблем в котором может произойти выход из строя всей системы. Вместо этого в Riak используется протокол сплетен, с помощью которого данные о вхождении узлов в кластер и принадлежности разделов системы распространяются по всей системе.

Протоколы сплетен, которые также называются протоколами эпидемий (epidemic protocols), работают именно так, как они называются. Когда узел в системе желает изменить часть совместно используемых данных, он делает изменения в своей локальной копии данных и сообщает об этом (сплетничает) другому узлу — своему случайному собеседнику. После получения обновления, узел объединяет полученные изменения со своим собственным локальным состоянием и еще раз сплетничает с другим случайным собеседником.

Когда кластер Riak запускается, все узлы должны быть сконфигурированы так, что в каждом из них находится одинаковое количество разделов. Затем кольцо последовательного хеширования делится на количество разделов, и каждый интервал запоминается в виде пары {HashRange, Owner} (хэш-кольцо, владелец). Первый узел в кластере просто объявляет об этом всем разделам. Когда в кластер добавляется новый узел, он в существующем узле добавляется в список пар {HashRange, Owner} этого узла. Затем объявляется о парах (количество разделов)/(число узлов), обновляется локальное состояние, которое будет отражать новое распределение разделов. Затем информация о распределении разделов будет с помощью протокола сплетен передана другому узлу. А затем это обновленное состояние будет с помощью описанного ранее алгоритма распространено по всему кластеру.

При использовании протокола сплетен в Riak не появляется единое место отказа в виде централизованного конфигурационного сервера, что избавляет системных операторов от необходимости хранить критически важные конфигурационные данные о кластере. В этом случае любой узел может в системе маршрутизации запросов использовать данные о назначении разделов, полученные с помощью протокола сплетен. Совместное использование протокола сплетен и последовательного хэширования позволяет системе Riak действительно функционировать как децентрализованная система, что очень важно при развертывании и эксплуатации крупномасштабных систем.

15.7. Заключение и усвоенные уроки

Большинство программистов считают, что чем меньше и проще код, его не только проще поддерживать, но в нем часто также меньше ошибок. Благодаря базовым примитивам языка Erlang, имеющимся в дистрибутиве, разработку Riak стало возможным начинать со считающимся фундаментальным слоя асинхронных сообщений и создавать свои собственные протоколы, не беспокоясь о том, что лежит в основе их реализации. Когда Riak перерос в зрелую систему, в некоторых частях его сетевой коммуникации отказались от использования встроенных средств языка Erlang (и перешли к прямому манипулированию сокетами TCP), а в других - продолжают использовать хорошо подходящие для этих целей встроенные примитивы языка. Начав с использования нативных сообщения языка Erlang, позволяющих передавать любые сообщения, команда разработчиков Riak смогла очень быстро построить всю систему. Эти примитивы достаточно понятны и ясны, так что позже их было просто заменить в нескольких местах, где они не лучшим образом вписались в систему.

Кроме того, благодаря особенностям механизма передачи сообщений Erlang и легковесности ядра виртуальной машины Erlang, пользователь может одинаково легко запустить 12 узлов на 1 машине или 12 узлов на 12 машинах. Это делает существенно упрощает разработку и тестирование в сравнении с более тяжеловесными механизмами передачи сообщений и кластеризации. Это особенно ценно из-за принципиально распределенного характера системы Riak. Исторически известно, что в большинстве распределенных систем очень трудно работать в "режиме разработки" на ноутбуке одного разработчика. В результате, разработчики часто заканчивают тестирование своего кода в среде, которая является лишь частью полной системой и ведет себя не так, как полная система. Поскольку кластер Riak со множеством узлов может абсолютно просто работать на одном ноутбуке без чрезмерного потребления ресурсов или сложных трюков с конфигурацией, в процессе разработки можно достаточно легко создавать код, который будет пригоден для развертывания в промышленных условиях.

Использование супервизоров Erlang/OTP делает Riak гораздо более устойчивым в условиях выхода из строя подкомпонентов. Riak позволяет делать даже больше; благодаря такому поведению, кластер Riak также может достаточно легко поддерживать функционирование даже тогда, когда во всех узлах произошел сбой и они исчезли из системы. Это порой может привести к удивительному уровню устойчивости. Одним из примеров этого был случай, когда крупное предприятие провело стресс-тестирование различных баз данных и умышленное их разрушение с целью выяснить их граничные возможности. Когда они добрались до Riak, у них возникли проблемы. Каждый раз, когда они находили способ (с помощью манипуляций на уровне операционной системы, плохих соединений IPC и т.д.), который должен был разрушить подсистему Riak, они могли наблюдать только очень короткий провал производительности, а затем система возвращалась к нормальному поведению. Это прямой результат вдумчивого подхода «пускай выходит из строя». Riak, когда это требовалось, перезапускал заново каждую из этих подсистем, а система в целом просто продолжала функционировать. Этот опыт показал, какой именно вид устойчивости Erlang/OTP вносит в создаваемые программы.

15.7.1. Благодарности

Эта глава основана на конспекте лекций 2009 года Франческо Чезарини (Francesco Cesarini) и Саймона Томпсона (Simon Thompson) Центрально-европейской школы функционального программирования, которая проводилась в Будапеште и Комарно. Основной вклад внес Саймон Томпсон (Simon Thompson) из Университета Кент в Кентербери, Великобритания. Отдельное спасибо всем рецензентам, которые высказывали свое мнение на различных этапах написания этой главы.

16.1. История

Джейсон Хогинс (Jason Huggins) приступил к проекту Selenium в 2004 году, когда он работал в ThoughtWorks над внутренним проектом компании — системой Time and Expenses (T&E), в которой широко использовался Javascript. Хотя в то время доминирующим браузером был Internet Explorer, в ThoughtWorks использовали ряд альтернативных браузеров (в частности, варианты Mozilla) и в случаях, когда приложение T&E не работало с выбранным браузером, требовалось собирать сообщения об ошибках. Инструменты тестирования с открытым исходным кодом, которые были на тот момент, либо предназначались только для одного браузера (обычно - для IE), либо моделировали работу браузера (например, HttpUnit). Стоимость лицензии на коммерческий инструмент могла оказаться разорительной для ограниченного бюджета небольшого внутреннего проекта, поэтому это даже не рассматривались в качестве допустимого варианта решения.

Там, где трудно автоматизировать, обычно полагаются на ручное тестирование. Такой подход не годится в ситуациях, когда команда разработчиков очень маленькая или когда релизы выходят очень часто. Также зря растрачиваются человеческие ресурсы, когда просят использовать скрипт, который может быть автоматизирован. Более прозаично то, люди медленнее, чем машина, выполняют скучные повторяющиеся задачи и оно при этом более подвержены ошибкам. Ручное тестирование не могло быть вариантом решения.

К счастью, во всех браузерах, в которых выполняется тестирование, поддерживают язык Javascript. Это привело Джейсона и его команду к мысли написать на этом языке инструмент тестирования, который можно было бы использовать для проверки поведения приложения. Вдохновленные работой, которая была проведена в рамках проекта FIT [1], они создали поверх языка Javascript синтаксические конструкции, имеющие вид таблиц, которые позволили тем, кто имел мало опыта в программировании, писать в файлах HTML тесты, в которых использовались ключи. Этот инструмент, первоначально называвшейся «Selenium», но позже переименованный в «Selenium Core», был выпущен в 2004 году под лицензией Apache 2.

Табличный формат в Selenium аналогичен по структуре формату ActionFixture из FIT. Каждая строка таблицы разделена на три столбца. В первом столбце указывается имя команды, которая должна быть выполнена, во втором столбце обычно находится идентификатор элемента, а в третьем столбце указывается дополнительное значение. Например, присваивание строки «Selenium WebDriver» элементу, идентифицируемому именем «q», осуществляется следующим образом:

type       name=q       Selenium WebDriver

Поскольку Selenium был написан на чистом языке Javascript, его первоначальный вариант для того, чтобы не нарушать правил политики безопасности браузера и не выходить за границы песочницы Javascript, требовал от разработчиков размещать пакет Core и его тесты на том же самом сервере, что и тестируемое приложение AUT (the application under test). Это не всегда было удобно или возможно. Еще хуже было то, что хотя оболочка IDE, имеющяася у разработчиков, предоставляла им возможность оперативно управлять кодом и ориентироваться в коде большого размера, для HTML такого инструмента не было. Быстро стало ясно, что поддержка наборов тестов даже средних размеров, громоздко и болезненно [2].

Для решения этого и других вопросов был написан HTTP прокси-сервер, который в Selenium мог перехватывать каждый запрос HTTP. Использование такого прокси-сервера позволило обойти многие из ограничений политики «только одного хоста», когда браузер не позволяет языку Javascript обращаться куда либо, кроме сервера, с которого была взята текущая страница, и это позволило смягчить первые недостатки. Такая схема дала возможность написать привязки Selenium для нескольких языков: в них было просто необходимо иметь возможность отправлять запросы HTTP на конкретный URL. Этот сетевой формат был тщательно промоделирован с помощью табличного синтаксиса Selenium Core, и стал, вместе с табличным синтаксисом, называться «Selenese». Поскольку языковыми привязками можно было управлять из браузерам на расстоянии, этот инструмент был назван «Selenium Remote Control» (механизмом дистанционного управления Selenium) или «Selenium RC».

Когда разрабатывался проект Selenium, в ThoughtWorks зарождался еще один фреймворк автоматизации браузеров: WebDriver. Исходный код для него был создан в начале 2007 года. WebDriver был создан в результате работы над проектами, в которых было необходимо изолировать всю линейку тестов, выполнявшимися на проектами так, чтобы используемые для тестов инструменты не влияли на проекты. Как правило, подобная изоляция выполняется с помощью шаблона Adapter. WebDriver появился в результате практического опыта, полученного при последовательном применении данного подхода в многочисленных проектах, и первоначально он представлял собой обертку вокруг HtmlUnit. Вскоре после его выпуска последовала поддержка для Internet Explorer и Firefox.

Когда WebDriver был реализован, он существенно отличался от Selenium RC, хотя оба пакета относились к одной и той же нише интерфейсов API, предназначенных для автоматизации браузеров. Самым очевидным различием для пользователя было то, что в Selenium RC был интерфейс API, в котором использовался словарь и в котором все методы были доступны из одного класса, тогда как в WebDriver был более объектно-ориентированный интерфейс API. Кроме того, в WebDriver поддерживалась работа только с языком Java, в то время как в Selenium RC предлагалась поддержка широкого спектра языков. Были также существенные технические различия: пакет Selenium Core (на основе которого был создан RC) был, по существу, приложением на JavaScript, работающим внутри изолированной среды браузера. В WebDriver за счет существенных затрат, вложенных в разработку самого фреймворка, была сделана попытка связать его с самим браузером с тем, чтобы можно было обойти модель безопасности, используемую в браузере.

В августе 2009 года было объявлено, что эти два проекта будут объединяться, и результатом этого объединения стал проект Selenium WebDriver. На момент написания данной главы, в WebDriver поддерживается привязка к таким языкам, как Java, C#, Python и Ruby. В нем предлагается поддержка для браузеров Chrome, Firefox, Internet Explorer, Opera и браузеров, используемых в Android и в iPhone. Есть дочерние проекты, которые не хранятся в этом репозитории исходного кода, но тесно взаимосвязаны с основным проектом и обеспечивают привязку к языку Perl, реализацию для браузера в BlackBerry и для "безоконного варианта" WebKit, что удобно в тех случаях, когда тесты нужно запускать с постоянным взаимодействием с сервером, но отображение не требуется. Продолжается поддержка исходного механизма Selenium RC и обеспечивается поддержка WebDriver для тех браузеров, работа с которыми по иному не поддерживается.

Продолжение статьи - Пару слов о жаргоне.

17.1. Как все начиналось…

Первые версии программы, которая будет позже известна как sendmail, были написаны в 1980 году. Программа началась как быстрый хак для передачи сообщений между различными сетями (Интернет в то время разрабатывался, но еще не был полнофункциональным). Предполагалось наличие множества различных сетей, и не было единого консенсуса на тему, какая из сетей будет главной. В США использовалась Арпанет, а Интернет конструировался как ее апгрейд, однако Европа решительно настаивала на OSI (Open Systems Interconnect), и какое-то время казалось, что OSI может взять верх. Обе сети использовали выделенные линии телефонных компаний; в США скорость таких линий составляла 56 kbps.

Вероятно, наиболее успешной сетью на то время, с точки зрения числа подключенных компьютеров и людей, была сеть UUCP, которая отличалась тем, что у нее абсолютно отсутствовал центральный орган управления. В каком-то смысле это была самая первая пиринговая сеть, работавшая через коммутируемые телефонные линии: 9600 bps была какое-то время максимальной скоростью. Самой быстрой сетью (3 Mbps) была сеть, основанная на Ethernet от Xerox и работавшая посредством протокола, названного XNS (Xerox Network Systems). Но она не работала вне локального окружения.

Обстановка того времени довольно сильно отличалась от того, что мы имеем сегодня. Компьютеры были разнотипны до такой степени, что не было даже полного согласия об использовании 8-битных байтов. Например, были компьютеры PDP-10 (36-битные слова, 9-битные байты), PDP-11 (16-битные слова, 8-битные байты), серия CDC 6000 (60-битные слова, 6-битные символы), IBM 360 (32-битные слова, 8-битные байты), XDS 940, ICL 470 и Sigma 7. На подходе была платформа Unix из Bell Laboratories. Большинство Unix-машин использовали 16-битное адресное пространство: в то время PDP-11 была главной машиной на Unix, Data General 8/32 и VAX-11/780 только появлялись. Потоки не существовали – вообще концепция динамических процессов была довольно новой (в Unix они были, но «серьезные» системы вроде IBM OS/360 их не имели). Блокировка файлов не поддерживалась ядром Unix (хотя сделать это было возможно используя ссылки файловой системы).

Те сети, которые существовали, были довольно низкоскоростными (многие использовали 9600-бодовые линии TTY; у самых богатых был Ethernet, но только локально). Почтенный интерфейс сокетов еще не будет изобретен в течение многих лет. Шифрование публичными ключами тоже еще не было изобретено, так что большая часть сетевой безопасности, такой как мы ее знаем сегодня, не была возможна.

Сетевая почта уже существовала в Unix, но она была создана через хаки. Основным пользовательским агентом в то время была команда /bin/mail (сегодня иногда называемая как binmail или v7mail), но у некоторых узлов сети были другие агенты, такие как Mail из Беркли, который действительно умел работать с отдельными сообщениями, а не просто был приукрашенной командой cat. Каждый пользовательский агент читал (а обычно и писал!) в /usr/spool/mail напрямую; не было абстракций для настоящего хранения сообщений.

Логика принятия решения о том, направлять ли сообщение в сеть или локально, определялась просто за счет проверки содержания в адресе восклицательного знака (для UUCP) или запятой (для BerkNET). Люди с доступом к Арпанет должны были использовать абсолютно отдельную почтовую программу, которая не работала с другими сетями, и которая даже хранила локальную почту в другом месте и другом формате.

Чтобы еще более все запутать, фактически тогда не было никакой стандартизации формата сообщений. Существовало общее соглашение о том, что в начале сообщения должен было быть блок полей заголовка, каждое поле заголовка должно было начинаться с новой строки, имена и значения полей должны были быть разделены запятой. Кроме этого не существовало никакой стандартазиации в выборе имен полей заголовка или синтаксисе индивидуальных полей. Например, некоторые системы использовали Subj: вместо Subject:, поля Date: использовали различный синтаксис, некоторые системы не понимали полные имена в поле From:

Кроме всего вышеперечисленного, то, что все-таки было задокументировано, зачастую было двусмысленным или не совсем использовалось. В частности, RFC 733 (который должен был описывать формат сообщений Арпанет) отличался от реально используемого формата в небольших, но значимых деталях, а метод непосредственной передачи сообщений вообще не был официально документирован (хотя несколько RFC ссылались на механизм, но не описывали его). Результатом было то, что система передачи сообщений напоминала колдовство.

В 1979 году проект управления реляционными базами данных INGRES (также известные в то время как “моя работа”) получили грант DARPA и вместе с ним 9600 bps подключение нашего PDP-11 к Арпанет. В то время это было единественное доступное соединение к Арпанет в отделе Computer Science, поэтому все хотели получить к нему доступ. Однако наша машина уже была забита и мы могли сделать доступными для всех только два порта. Это приводило к существенным разногласиям и частым конфликтам. Однако, я заметил, что люди обращались больше всего не к удаленному управлению или передаче файлов, а к электронной почте.

Среди всего этого sendmail (изначально названный delivermail) появился как попытка унифицировать хаос в одном месте. Каждый почтовый агент пользователя (или почтовый клиент) просто вызывал delivermail для доставки почты вместо того, чтобы определять как ее доставить самостоятельно, зачастую, произвольным (и непостоянным) образом. Delivermail/sendmail не предпринимали попыток диктовать, как локальная почта должна храниться или доставляться; программа не делала абсолютно ничего, кроме передачи почты между другими программами. (как мы скоро узнаем, эта ситуация изменилась, когда был добавлен SMTP). В каком-то смысле это был просто «клей» для совместной работы различных почтовых систем, вместо того чтобы являться самостоятельной почтовой системой.

Во время разработки sendmail Арпанет была преобразована в Интернет. Изменения не появлялись мгновенно и они были существенными: начиная от низкоуровневых пакетов при подключении до протоколов приложений.

Sendmail был буквально разработан одновременно со стандартами, а в некоторых случаях повлиял на них. Также примечательно то, что sendmail выжил и даже преуспел, несмотря на то, что Сеть (такая, какой мы знаем ее сегодня) выросла с нескольких сотен до миллионов хостов.

Другая сеть

Стоит упомянуть, что в то время был предложен еще один, абсолютно отдельный, почтовый стандарт, названный X.400, бывший частью ISO/OSI (International Standards Organization/Open Systems Interconnect). X.400 был бинарным протоколом, сообщения кодировались при помощи ASN.1 (Abstract Syntax Notation 1), который до сих пор используется некоторыми протоколами, например, LDAP. LDAP был свою очередь упрощением X.500, а тот – службой директорий, используемой X.400. Sendmail вообще никак не проектировался для прямой совместимости с X.400, хотя в то время существовали некоторые шлюзовые сервисы. Хотя X.400 был первоначально принят многими коммерческими компаниями того времени, Интернет-почта и SMTP стали стандартом де-факто.

17.2. Принципы разработки

Во время разработки sendmail я придерживался нескольких принципов. Все они в определенном смысле сводились к одному: делать как можно меньше. Это резко контрастирует с некоторыми другими проектами того времени, которые имели гораздо более широкие цели и требовали гораздо более громоздкой реализации.

17.2.1 Один программист ограничен в своих возможностях

Я писал sendmail как побочный неоплачиваемый проект. Моей целью было создать быстрый способ сделать почту Арпанет более доступной для людей в Беркли. Главным было перенаправлять почту между существующими сетями, все они были реализованы как отдельные программы, которые вообще не знали, что существует более, чем одна сеть. Изменение одним не полностью занятым программистом большого количества кода существующих программ было бы невозможно. В процессе разработки необходимо было минимизировать количество кода, который нужно было изменить, и количество вновь написанного кода. Эти ограничения влияли на остальные принципы разработки. Как оказалось, в большинстве случаев, они были правильными, даже если бы была доступна большая команда разработчиков.

17.2.2. Не переделывай пользовательские агенты

Почтовый агент пользователя — это то, что большинство пользователей подразумевают под «почтовой системой» — то есть та программа, которую они используют для чтения почты и ответов на письма. Она сильно отличается от агента передачи почты (MTA), который направляет e-mail от отправителя к получателю. В то время, когда был написан sendmail, многие реализации как минимум частично совмещали эти две функции, поэтому они часто разрабатывались совместно.

Работать над обеими функциями сразу было бы слишком тяжело, поэтому c sendmail я полностью отказался от идеи переделки пользовательского интерфейса: единственные изменения в пользовательских агентах были в том, чтобы они вызывали sendmail вместо выполнения собственной маршрутизации. Кроме того тогда уже было несколько пользовательских агентов, и люди стали привыкать к тому, как они работают с почтой. Пытаться работать над всеми функциями сразу не представлялось возможным. Такое разделение пользовательского агента от агента передачи почты естественно сейчас, но тогда это сильно отличалось от общепринятых решений.

17.2.3. Не переделывай хранилище локальной почты

Хранилище локальной почты (место, где должны сохраняться сообщения до тех пор, пока получатель их не прочтет) не было формально стандартизовано. Некоторые узлы предпочитали хранить ее в централизованном месте, таком как /usr/mail, /var/mail или /var/spool/mail. Другие хранили почту в домашней папке получателя (как файл .mail). Большая часть узлов начинала каждую строку с “From” и затем шел пробел (чрезвычайно неудачное решение, но так было принято в то время), но те узлы, которые нацеливались на Арпанет, обычно хранили сообщения, разделяя их строкой, содержащей четыре control-A символа.

Некоторые узлы пытались блокировать почтовый ящик для предотвращения коллизий, но они использовали разные договоренности о блокировке (примитивы блокирования файлов еще не были доступны). Короче говоря, единственной разумной вещью было считать хранилище локальной почты черным ящиком.

Практически на всех узлах механизм работы с хранилищем локальной почты был внедрен в программу /bin/mail. Она имела (довольно примитивный) пользовательский интерфейс, маршрутизацию и хранение, реализованные в одной программе. Для подключения sendmail часть, отвечающая за маршрутизацию, была вынута и заменена на вызов sendmail. Был добавлен флаг -d для возможности явного указания необходимости конечной доставки, другими словами он не давал вызвать sendmail из /bin/mail. В последующие годы код, используемый для доставки сообщения в физический почтовый ящик, был выделен в отдельную программу mail.local. Программа /bin/mail существует сегодня только как место вызова отправки почты, сама эти функции не выполняя.

17.2.4. Пусть sendmail подстраивается под мир, а не наоборот

Такие протоколы как UUCP и BerkNET уже были реализованы как отдельные программы, имеющие свои собственные, иногда мудреные, интерфейсы командой строки. В некоторых случаях они активно развивались одновременно с sendmail. Было понятно, что пытаться сделать иную их реализацию (например, для приведения к стандартным способам вызова) было бы неудобно. Это напрямую приводило к принципу, что sendmail должен адаптироваться к остальному миру, а не пытаться подстроить остальные программы под sendmail.

17.2.5. Менять как можно меньше

В максимально возможной степени во время разработки sendmail я не трогал то, что можно было не трогать. Кроме того, что на это просто не было времени, в то время в Беркли вместо более формальных правил определения принадлежности кода склонялись в пользу правила «кто последний трогал код, к тому и будем обращаться по поводу этой программы» (или проще говоря «тронул код, теперь он твой»). Хотя это звучит довольно беспорядочно по сегодняшним меркам, это работало в то время в Беркли, когда никто не трудился полный рабочий день над Unix; каждый работал над частью системы, которая им была интересна, и не трогал остальной код за исключением обстоятельств крайней необходимости.

17.2.6. Сразу думай о надежности

Почтовые системы до sendmail (включая большинство транспортных систем) не очень были озабочены надежностью. Например, версии Unix до 4.2BSD не имели встроенной поддержки блокировки файлов, хотя ее можно было симулировать, создав временный файл и затем связав его с файлом блокировки (если файл блокировки уже существовал, вызов link не работал). Однако, иногда различные программы, писавшие данные в один и тот же файл, не договаривались о том, как должна выполняться блокировка (например, они могли использовать различные имена для файлов блокировки или вообще не пытаться блокировать файл), и поэтому не было из ряда вон выходящим, если почта терялась. Sendmail занял другую позицию: терять почту было нельзя (возможно, как результат моего опыта работами с базами данных, где потеря данных является смертным грехом).

17.2.7. Что не было реализовано

Многие вещи не были реализованы в ранних версиях. Я не пытался переделать архитектуру почтовой системы или создать полноценное общее решение: функциональность могла быть добавлена по мере необходимости. Самые ранние версии не были даже полностью конфигурируемыми без доступа к исходным кодам и компилятору (хотя это было довольно быстро исправлено). В общем, modus operandi для sendmail было сделать как можно раньше работающую версию и затем улучшать работающий код по необходимости, когда проблема будет более ясна.

17.3. Фазы разработки

Как и у большинства долгоживущих проектов у sendmail было несколько этапов разработки, каждый со своей общей целью и ощущениями.

17.3.1. Волна первая: delivermail

Первая реализация sendmail была известна как delivermail. Она была чрезвычайно простой, если не сказать примитивной. Ее единственной задачей было перенаправлять сообщения от одной программы к другой; в частности, у нее не было поддержки SMTP, и она никогда не делала никаких прямых сетевых соединений. Не было необходимости в очереди, потому что каждая сеть имела свою, поэтому программа по сути была просто координатным коммутатором. Так как у delivermail не было прямой поддержки сетевых протоколов, не было причин запускать ее как демон – она вызывалась для перенаправления каждого сообщения, когда то отправлялось. Программа передавала сообщение подходящей программе, которая реализовывала следующий этап и завершалась.

Не было попыток переписывать заголовки для соответствия сети, в которую доставлялось сообщение. Это обычно приводило к тому, что на перенаправляемые сообщения нельзя было ответить. Ситуация была настолько плохой, что об адресации писем была написана целая книга (названная соответствующе !%@:: Руководство по адресации электронной почты и сетям [AF94]).

Вся конфигурация в delivermail была внутри исходного кода и основывалась на специальных символах в адресах. Символы имели приоритет. К примеру, конфигурация хоста могла искать знак “@” и, если таковой был найден, посылала весь адрес назначенному транслирующему хосту Арпанет. Если его не было, она могла искать запятую, и затем посылать сообщение назначенному хосту BerkNET и пользователю, если такой был найден, затем могла проверять наличие восклицательного знака (“!”), сигнализирующего о том, что сообщение должно быть передано ретранслятору UUCP. Если и такого не было, совершалась попытка локальной доставки. Эта конфигурация могла выглядеть вот так:

Input 	Sent To {сеть, хост, потзователь}
foo@bar 	{Arpanet, bar, foo}
foo:bar 	{Berknet, foo, bar}
foo!bar!baz 	{Uucp, foo, bar!baz}
foo!bar@baz 	{Arpanet, baz, foo!bar}

Обратите внимание, что разделители в адресе указывались по-разному, что приводило к путанице, которая могла быть исправлена только при помощи эвристических методов. Например, последний из приведенных примеров мог вполне быть распарсен как {Uucp, foo, bar@baz} на одном из узлов.

Конфигурация была внедрена в исходный код по нескольким причинам: во-первых, из-за ограниченной памяти и 16-битного адресного пространства, парсинг конфигурации во время выполнения программы был бы слишком затратным. Во-вторых, системы того времени были настолько сильно заточены под собственые задачи их владельцев, что перекомпиляция была сама по себе хорошей идеей, просто чтобы убедиться, что у вас есть локальные версии используемых в них библиотек (совместно используемые библиотеки не существовали в Unix 6).

Delivermail распространялась с 4.0 и 4.1 версиями BSD и была более успешной, чем ожидалось; Беркли был далеко не единственным узлом с гибридной сетевой архитектурой. Стало ясно, что требовалась дополнительная работа над этим проектом.

17.3.2. Волна 2: sendmail 3, 4 и 5

Версии 1 и 2 распространялись под названием delivermail. В марте 1981 началась работа над версией 3, которая должна была выйти под именем sendmail. В это время 16-битная PDP-11 все еще была распространена, но 32-битная VAX-11 становилась все популярнее, так что многие имевшиеся изначально ограничения, связанные с малым пространством адресов, отходили в прошлое.

Первоочередными задачами для sendmail были переход на конфигурирование во время выполнения, разрешение модификаций сообщений для обеспечения совместимости между сетями для перенаправляемой почты, а также более богатый язык, на основе которого будут приниматься решения о маршрутизации. Для этого по существу использовалась текстовая перезапись адресов (основанная на токенах, а не символьных строках), этот механизм использовался в некоторых экспертных системах того времени. Существовал специально написанный для этого код для извлечения и сохранения всех строк комментариев (в скобках) и последующей их вставки после того, как программная перезапись была завершена. Было также важно иметь возможность добавлять или пополнять поля заголовков (например, добавлять заголовок Date или включать полное имя отправителя в заголовок From, если оно было известно).

Разработка SMTP началась в ноябре 1981. Исследовательская группа компьютерных наук в университете Беркли получила контракт DARPA на разработку платформы, основанной на Unix для поддержки исследований, спонсируемых DARPA, с целью сделать совместный доступ между проектами проще.

Первоначальная работа над стэком TCP/IP была к тому времени завершена, хотя детали интерфейса сокетов еще модифицировались. Основные протоколы приложений, такие как Telnet и FTP, были завершены, но SMTP еще не был реализован. На самом деле протокол SMTP еще даже не был окончен на тот момент; были большие дискуссии на тему того, что почта должна была пересылаться при помощи протокола, креативно названного Протокол передачи почты (Mail Transfer Protocol – MTP). В то время, как дебаты разгорались, MTP становился все более и более сложным до тех пор, пока от разочарования в нем не был создан набросок протокола SMTP (Простой Протокол Передачи Почты – Simple Mail Transfer Protocol – SMTP), который не был официально опубликован до августа 1982 года. Официально я работал на INGRES Relational Database Management System, но так как я знал о почтовых системах больше, чем кто-либо в Беркли в то время, со мной стали говорить о реализации SMTP.

оей первой мыслью было создать отдельного SMTP-отправителя, который имел бы свою собственную организацию очередей и демон; эта подсистема подсоединялась бы к sendmail для маршрутизации. Однако, некоторые особенности SMTP делали это проблематичным. Например, команды EXPN и VRFY требовали доступа к парсингу, созданию алиасов и модулю проверки локальных адресов. Также, в то время я считал важным, чтобы команда RCPT возвращала результат мгновенно, если адрес был не известен, а не принимала сообщение и затем отправляла бы сообщение о неудавшейся доставке позже. Это оказалось очень дальновидным решением. Забавно, что позже агенты передачи почты часто реализовывали этот момент неправильно, усугубляя проблему обратной отправки спама. Эти проблемы привели к решению сделать SMTP частью sendmail.

Sendmail 3 распространялась с версиями 4.1а и 4.1с BSD (бета версии), sendmail 4 распространялся с версией 4.2 BSD, а sendmail 5 – с версией 4.3 BSD.

17.3.3. Волна 3: Годы хаоса

После того, как я покинул Беркли и перешел в стартап, время на работу над sendmail резко сократилось. Но интернет начинал серьезно набирать обороты и sendmail использовался в различном новом, и все более масштабном, окружении. Большинство производителей Unix-систем (Sun, DEC и IBM) в частности создали свои собственные версии sendmail, взаимно не совместимые между собой. Были также попытки создать opensource-версии, в частности IDA sendmail и KJS.

IDA sendmail появилась из университета Linköping University. IDA включала расширения для более легкой установки и работы в масштабируемых системах, а также абсолютно новую систему конфигурации. Одной из главных новых функций было включение базы данных dbm(3) для поддержки узлов с динамическим содержимым. Поддержка базы данных была возможна за счет нового синтаксиса конфигурационного файла и использовались для многих функций, включая преобразование адресов в/из внешнего синтаксиса (например, отправка письма на ящик john_doe@example.com вместо johnd@example.com) и для маршрутизации.

Sendmail короля Джеймса (KJS, разработана Paul Vixie) была попыткой объединения всех различных версий sendmail. К сожалению, она так и не получила достаточной поддержки для достижения желаемого результата. Эта эра была также обозначена множеством новых технологий, которые отразились на почтовой системе.

Например, создание Sun бездисковых кластеров добавило службу директорий YP (позже NIS) и NFS, сетевую файловую систему. В частности YP должна была быть видимой для sendmail, т.к. алиасы хранились в YP, а не в локальных файлах.

17.3.4 Волна 4: sendmail 8

Спустя несколько лет я вернулся в Беркли как штатный сотрудник. Моей работой было управление группой, занимавшейся установкой и поддержкой инфраструктуры с совместным доступом для исследований отдела Вычислительной техники. Чтобы решать поставленные задачи, созданные главным образом спонтанно исследовательские группы должны были быть объединены каким-то рациональным образом. Во многом как и в ранние дни Интернета, различные исследовательские группы работали на абсолютно различных платформах, некоторые из них были довольно устаревшими. В общем, каждая группа имела свои системы, и хотя некоторые из них были хорошо управляемы, большинство страдало от «отложенного ремонта».

В большинстве случаев электронная почта также была не структурирована. У каждого был адрес электронной почты “person@host.berkeley.edu”, где хостом было название рабочей станции в их офисе или используемый ими общий сервер (университет не имел внутренних поддоменов), за исключением нескольких особенных людей, которые имели адрес @berkeley.edu. Задачей было перейти на внутренние поддомены (так чтобы все индивидуальные хосты имели поддомен cs.berkeley.edu) и объединенную почтовую систему (чтобы каждый человек имел адрес @cs.berkeley.edu). Эту задачу легче всего было решить, создав новую версию sendmail, которую можно было бы использовать во всем отделе.

Я начал с изучения различных вариантов sendmail, получивших популярность. В мои намерения не входило начинать с абсолютно нового кода, а скорее понять функциональность, которую другие нашли для себя полезной. Многие из тех идей были реализованы в sendmail 8, часто с модификациями для совмещения со схожими идеями или для того, чтобы представить их в более общем виде. Например, несколько версий sendmail имели возможность доступа к внешним базам данных, таких как dbm(3) или NIS; sendmail 8 объединил их в один механизм преобразования, который мог работать со многими базами данных (и даже с преобразованием произвольных данных не из баз данных). Аналогично, была включена обобщенная работа с базами данных (сопоставление внутренних и внешних имен) из IDA sendmail.

Sendmail 8 также включал новый конфигурационнй пакет, использующий m4(1) макропроцессор. Он был более декларативным, чем конфигурационный пакет sendmail 5 (главным образом процедурный). То есть sendmail 5 требовал от администратора полностью вручную создавать конфигурационный файл, используя только возможность “include” из m4 для удобства. Конфигурационный файл sendmail 8 позволял администратору задавать, какие функции, отправители сообщений и так далее требовались, а m4 выдавал конечный конфигурационный файл.

Большая часть секции 17.7 описывает улучшения в sendmail 8.

17.3.5 Волна 5: Коммерческие годы

С ростом Интернета и увеличения количества серверов, использовавших sendmail, поддержка больших баз пользователей становилась все более проблематичной. Какое-то время я мог продолжать этот процесс, создав группу добровольцев (неформально названную “Консорциум Sendmail”, или sendmail.org), которые обеспечивали бесплатную поддержку по e-mail и через группу новостей. Но к концу 1990-х количество пользователей выросло настолько, что их практически невозможно было поддерживать на добровольной основе. Вместе с более бизнес-ориентированным другом я создал Sendmail, Inc., ожидая получить новые ресурсы для работы над кодом.

Хотя коммерческий продукт был изначально главным образом ориентирован на конфигурационные инструменты, многие новые функции были добавлены в агент передачи посты с открытым исходным кодом для поддержки нужд коммерческого мира. В частности, компания добавила поддержку TLS (шифрование соединения), SMTP-аутентификации, улучшения в безопасности узлов, такие как защиту от отказа в обслуживании, и, самое важное, плагины для фильтрации почты (интерфейс Milter’а описывается ниже).

На момент написания этой книги коммерческий продукт разросся настолько, что стал включать большие пакеты приложений, основанных на e-mail, большинство которых были созданы на основе расширений, добавленных в sendmail во время первых лет существования компании.

17.3.6. Что произошло с sendmail 6 и 7?

Sendmail 6 по сути был бета-версией sendmail 8. Эта версия никогда не была официально выпущена, но получила довольно широкое распространение. Sendmail 7 вообще никогда не существовало; сразу была выпущена версия 8, потому что все остальные файлы исходных кодов для дистрибутива BSD были выпущены как версия 8, когда BSD 4.4 была выпущена в июне 1993.

17.4. Проектные решения

Некоторые решения в процессе проектирования были правильными. Некоторые начинались правильно, но стали неверными, так как мир успел измениться. Некоторые были сомнительными и так и не стали менее сомнительными со временем.

17.4.1. Синтаксис конфигурационного файла

Синтаксис конфигурационного файла преследовало несколько проблем. Во-первых, все приложение должно было помещаться в 16-битном адресном пространстве, поэтому парсер должен был быть маленьким. Во-вторых, первые конфигурации были довольно небольшими по размеру (до одной страницы), поэтому несмотря на запутанный синтаксис, файл все равно оставался читаемым. Однако по прошествии времени все больше решений по работе программы переносились из кода C в конфигурационный файл, и файл начал разрастаться. Конфигурационный файл приобрел репутацию загадочного. Одной из проблем для многих был выбор символа табуляции в качестве активного синтаксического элемента. Это была одна из ошибок, скопированная с других систем того времени, в частности make. Эта ошибка стала более критичной с появлением оконных систем (а с ними копирования и вставки, которые обычно не сохраняют табуляцию).

Оглядываясь назад, я понимаю, что так как файл становился все больше и начали появляться 32-битные машины, имело смысл пересмотреть синтаксис. Было время, когда я раздумывал над этим, но решил не делать, т.к. не хотел изменять конфигурации на «большом» количестве машин (которое в то время составляло несколько сотен). Как оказалось это было ошибкой; я просто недооценил насколько вырастет количество инсталляций программы и сколько часов я бы сэкономил, если бы поменял синтаксис раньше. Также, когда стандарты устоялись, большое количество опций можно было вернуть в код программы, упростив ее настройку.

Отдельный интерес представляет то, насколько много функциональности добавилось в конфигурационный файл. Я разрабатывал sendmail одновременно с развитием стандарта SMTP. За счет перемещения функциональных решений в конфигурационный файл я мог быстро отвечать на изменения в стандарте – обычно в течение 24 часов. Я думаю, что это улучшило SMTP-стандарт, так как представлялось возможным получить опыт работы с предлагаемыми изменениями достаточно быстро, хотя ценой этого стал трудночитаемый конфигурационный файл.

17.4.2. Правила преобразования (Rewriting Rules)

Одним из трудных вопросов при написании sendmail было то, как делать необходимые преобразования для разрешения пересылки между сетями без нарушения стандартов получающей сети. Требовались изменения метасимволов (например, BerkNET использовала запятую в качестве разделителя, которую нельзя было применять в адресах в SMTP), перемещения компонентов адресов, добавления или удаления компонентов и так далее. Например, следующие преобразования были необходимы в определенных обстоятельствах:

ИзВ

a:foo

a.foo@berkeley.edu

a!b!c

b!c@a.uucp

<@a.net,@b.org:user@c.com>

<@b.org:user@c.com>

Регулярные выражения не были лучшим выбором, потому что они не имели хорошей поддержки для границ слов, кавычек и т.д. Быстро стало ясно, что практически невозможно написать регулярные выражения, которые были бы точными, и уж тем более вразумительными. В частности регулярные выражения резервировали несколько метасимволов, включая “.”, “*”, “+”, “{[}" и "{]}”, а все они могли появляться в e-mail адресах.

Эти символы могли быть экранированы в конфигурационных файлах, но я считал это сложным, запутывающим и достаточно уродливым (такое решение попробовали в UPAS в Bell Laboratories, мейлер из Unix 8, но он никогда не завоевал популярность). Вместо этого, был необходим этап сканирования для создания токенов, которыми затем можно было манипулировать также как символами в регулярных выражениях. Единственного параметра, описывающего «управляющие символы», которые сами были и токенами, и разделителями токенов, было достаточно. Пустые пробелы разделяли токены, но сами токенами не были. Правила преобразования были просто паттернами сопоставления и замены пар, организованные по сути в подпрограммы.

Вместо большого количества метасимволов, которые нужно было экранировать, чтобы убрать их «магические» свойства (как они использовались в регулярных выражениях), я использовал один единственный «экранирующий» символ, который комбинировался с обычными символами для представления паттернов с произвольным символом (например, для поиска произвольного слова).

Традиционным подходом Unix было использование обратного слэша, но обратный слэш уже использовался как символ кавычек в некоторых адресах. Как оказалось, “$” был одним из немногих символов, которые еще не использовались как пунктуационные символы в синтаксисе email.

Одним из первональных неудачных решений было, как ни странно, то, как использовались пробелы. Символ пробела был разделителем, как и в большинстве вводимых с компьютера данных, и поэтому мог быть использован свободно между токенами в паттернах. Однако, первые распространяемые конфигурационные файлы не включали пробелы, что приводило к паттернам, которые было понять труднее, чем это было необходимо. Посмотрите на разницу между следующими двумя (семантически идентичными) паттернами:

$+ + $* @ $+ . $={mydomain}
$++$*@$+.$={mydomain}

17.4.3. Использование преобразования для парсинга

Некоторые предлагали, что sendmail должен использовать для разбора адресов стандартные техники парсинга, основанные на грамматике, а не правила преобразования и оставить их для модификации адресов. На первый взгляд это имело смысл, учитывая, что стандарты, определяющие адреса, используют грамматику. Главной причиной повторного использования правил преобразования было то, что в некоторых случаях было необходимо разбирать адреса поля заголовка (например, чтобы выбрать конверт отправителя из заголовка при получении почты из сети, не имевшей формального конверта). Такие адреса нелегко распарсить, используя, скажем, парсер LALR(1) вроде YACC и традиционный сканер, из-за количества символов, которые необходимо просмотреть впереди.

Например, разбор адреса allman@foo.bar.baz.com <eric@example.com> требует просмотра вперед или сканером или парсером; вы не можете знать что первоначальный “allman@…” не является адресом до тех пор, пока не увидите “<”. Так как парсеры LALR(1) имеют только один токен для просмотра вперёд, это будет необходимо делать в сканере, что заметно его усложнит. По той причине, что правила преобразования уже поддерживали произвольный перебор с возвратами (то есть они могли смотреть вперёд произвольно далеко), их было достаточно.

Второй причиной было то, что через паттерны было относительно легко распознать и исправить неверный ввод. В конце концов, преобразования через правила были более чем мощными для данной задачи, а повторное использование кода было мудрым решением.

Один необычный момент о правилах преобразования: при сопоставлении с паттерном полезно и для ввода и для паттерна создать токены. По этой причине один и тот же сканер был использовани для вводимых адресов и для самих паттернов преобразования. Для этого сканер вызывался с различными таблицами типов символов для различных вводимых данных.

17.4.4. Внедрение SMTP и организации очереди в sendmail

«Очевидным» способом реализации исходящей (клиентской) части SMTP было бы сделать его как внешний отправитель, схожий с UUCP, но это привело бы к ряду вопросов. Например, где нужно было делать организацию очередей – в sendmail или другом клиентском SMTP-модуле? Если делать ее в sendmail, тогда отдельные копии сообщений нужно было рассылать каждому получателю (т.е. нельзя было бы открыть одно соединение и затем рассылать несколько RCPT-команд) или гораздо более сложный способ обратной коммуникации был бы необходим для передачи необходимого состояния каждого получателя, чем это было возможно с использованием простых кодов завершения Unix.

Если организация очереди была сделана в клиентском модуле, то был большой потенциал для репликации; в частности в то время другие сети, например, XNS еще были возможными соперниками. Кроме того, включение очереди в сам sendmail предоставляла более элегантный способ работы с определенного рода ошибками, в частности кратковременными проблемами, такими как нехватка ресурсов.

Входящая (серверная) часть SMTP подразумевала другой набор решений. В то время я считал важным реализовать VRFY и EXPN SMTP-команды точно, что требовало доступа к механизму алиасов. Это опять же потребовало бы гораздо более сложного протокола обмена между серверным модулем SMTP и sendmail, чем было возможно при использовании командных строк и кодов завершения (на самом деле, что-то вроде самого протокола SMTP для такой коммуникации).

Сегодня я бы гораздо более склонялся к тому, чтобы оставить реализацию очереди в ядре sendmail, но переместить обе части реализации SMTP в другие процессы. Одна из причин для этого в безопасности: как только на стороне сервера открыт 25 порт, нет необходимости в доступе администратора. Современные расширения, такие как TLS и DKIM-подписывание, усложняют клиентскую сторону (т.к. секретные ключи не должны быть доступны непривилегированным пользователям), но строго говоря доступ под администратором все равно так и не нужен. Несмотря на то, что если SMTP-клиент работает как пользователь-неадминистратор, имеющий возможность на чтение секретных ключей, проблема безопасности остается, у этого пользователя по определению есть особые привилегии, и поэтому он не должен напрямую осуществлять коммуникации с другими узлами. Все эти проблемы можно обойти довольно просто.

17.4.5. Реализация очереди

Sendmail следовал представлениям того времени о хранении файлов очереди. На самом деле используемый формат был чрезвычайно похож на подсистему lpr (линейного печатающего принтера) того времени. Каждое задание имело два файла, один с контрольной информацией и один с данными. Контрольный файл был простым текстовым файлом, первый символ каждой строки содержал информацию о значении этой строки.

Когда sendmail хотел обработать очередь, ему нужно было прочитать все контрольные файлы, сохраняя всю нужную информацию в память, и затем сортировать этот список. Это хорошо работало для относительно маленького количества сообщений, но при числе сообщений в очереди больше 10 000 не давало нужный результат. В частности, когда папка становилась настолько большой, что требовала блоков с непрямым доступом в файловой системе, возникала серьезная проблема с производительностью, которая могла снизить быстродействие на порядок. Было возможно улучшить данную ситуацию, заставив sendmail понимать несколько директорий с данными очередей, но это был в лучшем случае хак.

Альтернативным подходом было хранение всех контрольных файлов в базе данных. Это не было сделано, потому что в то время не было доступных пакетов баз данных, а когда появилась dbm(3), у нее было несколько недостатков, включая невозможность возвращения места на диске, требование о том, что все ключи, хэшируемые вместе, должны помещаться на одной странице (512 байт) и отсутствие блокировки. Надежные пакеты баз данных не появлялись еще многие годы.

Еще одной возможностью альтернативной реализации было создание отдельного демона, хранящего состояние очереди в памяти, возможно, записывающего также информацию в лог для возможности восстановления. Но по причине относительно небольшого объема почтового траффика в то время, недостатка памяти на большинстве машин, относительно высоких затрат на фоновые процессы и сложность реализации такого процесса, это решение вряд ли было хорошим в то время.

Еще одним проектировочным решением было хранение заголовков сообщения в контрольном файле очереди, а не в файле данных. Объяснением этому было то, что большинство заголовков требовали существенных изменений, зависящих от места назначения (а так как сообщения могли иметь более одного места назначения, их нужно было изменять несколько раз), и цена разбора заголовков оказывалась достаточно высокой, поэтому хранение их в формате, предварительно подготовленном для разбора, казалось экономией.

В ретроспективе это оказалось не лучшим решением, также как и хранение тела сообщения в стандартном формате Unix (с завершающими символами начала строки), а не в формате, в котором они были получены (который мог использовать символы начала новой строки, возврата каретки/перевода строки, просто возврата каретки или перевода строки/возврата каретки). С развитием мира электронной почты и принятием стандартов, необходимость в перезаписи сократилась, а даже кажущееся безобидным преобразование содержит риск ошибки.

17.4.6. Получение и исправление неверного ввода

Так как sendmail был создан в мире множества протоколов и на редкость малого количества стандартов, я решил приводить в порядок плохо сформированные сообщения, когда это возможно. Это соответствует “принципу устойчивости” (он же закон Постеля), сформулированному в RFC 7934. Некоторые из этих изменений были очевидными и даже обязательными: при отправке UUCP сообщения в Арпанет адреса UUCP нужно было конвертировать в адреса Арпанет, чтобы команда “Ответить” работала корректно, завершающие строки символы нужно было конвертировать между форматами, используемыми различными платформами, и так далее. Некоторые были менее очевидными: если сообщение было получено, но не включало заголовок “From:”, которое было обязательным согласно спецификациям Интернета, нужно ли было добавлять это поле заголовка, передавать сообщение без него или отказать в отправке?

В то время моей главной заботой была функциональная совместимость, поэтому sendmail исправлял сообщение, например, добавляя поле заголовка From:. Однако говорили о том, что это позволяло другим старым почтовым системам существовать еще долгое время после того, как они уже должны были быть исправлены или от них нужно было вовсе отказаться.

Я думаю, что мое решение было верным для того времени, но сегодня является причиной проблем. Высокая степень функциональной совместимости была важна, чтобы поток писем отправлялся без препятствий. Если бы я отклонял неверно сформированные сообщения, большинство сообщений в то время были бы отклонены. Если бы я пропускал их неисправленными, получатели бы получали сообщения, на которые не могли бы ответить и в некоторых случаях – сообщения, по которым нельзя было бы определить, кем они были отправлены, или же сообщения были бы отклонены другим почтовым отправителем.

Сегодня существуют стандарты, и по большей части эти стандарты точные и полные. Больше нет проблем в том, что многие сообщения будут отклонены. И тем не менее существует почтовое программное обеспечение, которое рассылает некорректно сформированные почтовые сообщения. Это создает многочисленные ненужные проблемы для других программ в Интернете.

17.4.7. Конфигурирование и использование M4

Какое-то время я одновременно регулярно вносил изменения в конфигурационные файлы sendmail и лично поддерживал многие машины.

Так как большое количество конфигурационных файлов на разных машинах оставалось одинаковым, становилось желательно использовать какой-то инструмент для создания конфигурационных файлов. Макропроцессор m4 был включен в Unix. Он был разработан как клиентская часть программных языков (в частности, ratfor). Что важнее, у него были возможности “include”, как инструкция “#include” в языке C. Первоначальные конфигурационные файлы использовали немногим больше этой возможности и небольшие макро-расширения.

IDA sendmail также использовала m4, но абсолютно другим образом. Оглядываясь назад, я думаю, что мне нужно было изучать эти образцы более детально. В них содержалось много умных идей, в частности то, как обрабатывалось экранирование.

Начиная с sendmail 6 конфигурационные файлы m4 были полностью переписаны в более декларативном стиле и стали гораздо меньше. Они использовали гораздо больше возможностей процессора m4, что привело к некоторым проблемам, когда появление GNU m4 изменило некоторую часть семантики в небольшой степени.

Изначально замысел был в том, что конфигурации m4 должны были следовать правилу 80/20: они должны были быть простыми (отсюда 20% работы) и должны покрывать 80% случаев. Довольно быстро стало понятно, что этого не получилось, по двум причинам.

Не самой важной причиной оказалось то, что было довольно легко конфигурировать большую часть случаев, по крайней мере по началу. Но это стало гораздо сложнее по мере развития sendmail и мира, особенно при включении таких функций как TLS-шифрования и SMTP-аутентификации.

Важная причина была в том, что использовать обычные конфигурационные файлы было слишком сложным для большинства людей. По сути формат файлов .cf (необработанный) стал компонующим автокодом – в принципе редактируемым, но в реальности довольно запутанным. “Исходный код” представлял собой m4 скрипт в файлах .mc

Еще одним важным различием было то, что конфигурационный файл в необработанном виде представлял собой язык программирования. В нем были процедуры (наборы правил), вызовы подпрограмм, детализация параметров и циклы (но без опереторов перехода goto). Синтаксис был запутанным, но во многих случаях напоминал команды sed и awk, по крайней мере по сути.

Формат m4 был декларативным: хотя было возможно использовать низкоуровневый язык, на практике эти детали были скрыты от пользователя.

Неясно, было ли это правильным или неправильным решением. Я считал в то время (и до сих пор считаю), что при создании сложных систем можно сделать то, что называется специализированным языком (Domain Specific Language – DSL) для создания определенных разделов этой системы. Однако, использование такого языка как методологии конфигурирования для конечного пользователя по сути превращает все попытки конфигурирования этой системы в проблему программирования. В этом заключены большие возможности, но и цена этого также высока.

17.5. Другие соображения

Некоторые другие проектировочные и архитектурные моменты, которые стоит отметить.

17.5.1. Об оптимизации масштабных Интернет-систем

В большинстве сетевых систем существуют конфликты между клиентом и сервером. Хорошая стратегия для клиента может быть неверной для сервера и наоборот. Например, при возможности на сервере стараются минимизировать затраты на обработку данных, перекладывая эти обязанности как можно больше на клиента, и конечно клиенту также необходимы подобные меры, но в обратном направлении.

Например, на сервере может быть выгоднее держать соединение открытым, обрабатывая спам-сообщения, так как это снижает затраты на отклонение сообщений (что на сегодняшний день является обычным делом), но клиент хочет отключиться как можно быстрее. Если посмотреть на всю систему целиком, Интернет в целом, оптимальным решением может быть баланс между этими двумя необходимостями.

Существовали такие агенты передачи почты, которые явно предпочитали один или другой способ – клиента или сервера. Они могут себе это позволить только потому, что у них довольно немного установок. Если ваша система используется на значительной части Интернета, вам необходимо спроектировать ее так, чтобы сбалансировать нагрузку между двумя сторонами в попытках оптимизировать Интернет как целое. Эта задача осложняется тем, что всегда будут существовать агенты передачи, полностью использующие только один подход, например, массовые почтовые системы, заботящиеся только об оптимизации исходящей стороны.

При разработке системы, которая включает обе стороны соединения, важно избегать подобных предпочтений. Заметьте, что это находится в разительном контрасте с обычной ассиметрией клиентов и сервисов, например, веб-серверы и веб-клиенты обычно не разрабатываются одной и той же группой.

17.5.2. Milter

Одним из наиболее важных дополнений к sendmail был интерфейс milter (mail filter). Milter позволяет использование сторонних плагинов для обработки почты. Изначально они разрабатывались для антиспамовой обработки. Протокол milter работает синхронно с протоколом сервера SMTP. По мере того, как каждая новая команда SMTP, получается от клиента, sendmail вызывает milter с информацией от этой команды. Milter имеет возможность принять эту команду или послать отказ, что отклоняет фазу протокола, соответствующую команде SMTP. Фильтры Milter созданы как callback-функции, поэтому когда приходит команда SMTP, вызывается соответствующая подпрограмма milter. Фильтры Milter работают как подпроцессы, указатель контекста передается в каждом соединении для каждой подпрограммы, что позволяет передачу текущего состояния.

В теории фильтры milter могли работать как подгружаемые модули в пространстве адресов sendmail. Мы отклонили это решениие по трем причинам. Во-первых, вопросы безопасности были слишком значительны: даже если sendmail был запущен под уникальным пользователем-неадминистратором, этот пользователь имел бы доступ ко всем состояниям всех остальных сообщений. Было неизбежно, что некоторые авторы фильтров milter попытались бы получить доступ к внутреннему состоянию sendmail. Во-вторых, мы хотели создать экран между sendmail и фильтрами milter: если фильтр падал, мы хотели, чтобы было ясно, чья была ошибка, и при этом потенциально почта продолжала отправляться. В-третьих, автору фильтра было гораздо легче отлаживать отдельный процесс, а не весь sendmail. Быстро стало понятно, что фильтры были полезны не только для обработки спама. Сайт milter.org содержит список фильтров для борьбы со спамом, вирусами, архивации, мониторинга контента, логирования, изменения траффика и многих других категорий. Фильтры создаются коммерческими компаниями и в качестве проектов с открытым исходным кодом. Программа postfix также добавила поддержку фильтров milter, используя тот же самый интерфейс. Фильтры Milter оказались одним из самых успешных введений sendmail.

17.5.3. Расписание релизов

Довольно популярны споры между двумя подходами – “выпускай быстро и часто” и “выпускай стабильные системы”. Sendmail использовал и тот, и другой подход в разное время. Во время значительных изменений иногда я делал более, чем один релиз в день. Моей общей философией было делать релиз после каждого изменения. Это похоже на предоставление публичного доступа к системному дереву управления исходным кодом. Лично я предпочитаю делать релизы, а не давать публичный доступ к дереву исходного кода, отчасти потому что я использую управление исходным кодом таким образом, который сейчас не одобряется: при больших изменениях я заношу в систему управления контроля версий нефункционирующие версии кода. Если дерево открыто для общего доступа, я использую для этих версий ветки (branches), но в любом случае они доступны для всех и это может привести к значительной путанице. Кроме того, создание релиза означает задание для него номера, а это облегчает отслеживание изменений при работе с баг-репортами. Конечно, это требует того, чтобы релизы можно было делать легко, а это не всегда так.

Со временем, когда sendmail начал использоваться во все более критичном окружении, это стало вызывать проблемы. Другим не всегда было легко понять, было ли выпущенное изменение для тестирования или оно действительно могло использоваться в продакшне. Наименование релизов как “альфа” или “бета” помогало, но не решало проблему. Результатом стало то, что с развитием sendmail перешел от более частых к более объемным релизам. Это стало особенно критично, когда sendmail стал выпускаться коммерческой компанией, клиенты которой хотели последние и самые лучшие и при этом одновременно и стабильные версии и не приняли бы того, что релизы нестабильны.

Эти разногласия между нуждами open source разработчиков и коммерческим продуктом никогда не исчезнут. Есть множество преимуществ в ранних и частых релизах, в частности потенциально большая аудитория отважных (и иногда глуповатых) тестировщиков, которая проверяет систему способами, которые вы никогда не ожидали бы повторить в стандартной системе разработки. Но как только проект становится успешным, есть тенденция превратить его в продукт (даже если этот продукт с открытыми исходными кодами и бесплатный), а продукты имеют другие потребности, нежели проекты.

17.6. Безопасность

В том, что касается безопасности, у sendmail была беспокойная жизнь. Некоторая часть из плохой славы была заслужена, но некоторая нет, так как концепция “безопасности” менялась на наших глазах.

Интернет начинался как база пользователей размером в несколько тысяч человек, главным образом из академических и исследовательских кругов. Во многом это был более добрый и мягкий интернет, нежели тот, что мы знаем сегодня. Сеть была спроектирована таким образом, чтобы поощрять распространение материалов, а не создание фаерволов (эта концепция вообще не существовала в первые дни). Сегодня сеть — это опасное, враждебное место, наполненное спамерами и хакерами. Все чаще она описывается как зона боевых действий, а в зоне военных действий есть потери среди мирных жителей. Трудно писать сетевые сервера безопасно, особенно если протокол хоть чуть-чуть сложен. Практически все программы имели как минимум небольшие проблемы; даже обычные реализации TCP/IP были успешно атакованы. Высокоуровневые языки показали, что также не являются панацеей, и даже добавили свои собственные уязвимости. Появилась необходимая фраза-предупреждение: “Не доверяй всем вводимым данным”, не важно от кого они получены. Недоверие к вводимым данным включает в себя и вторичные данные, например, от DNS-серверов и фильтров milter. Как и большинство первых сетевых программ sendmail был слишком доверчивым в своих первых версиях.

Но самой главной проблемой sendmail было то, что ранние версии работали под администратором. Права администратора были необходимы для открытия слушающего сокета SMTP, для чтения информации о передаче почты индивидуальных пользователей и для доставки почты в почтовые ящики индивидуальных пользователей и их домашние папки. Однако на большинстве сегодняшних систем концепция почтового ящика была отделена от концепции пользователя системы, что эффективно исключило необходимость в административном доступе, за исключением открытия слушающего сокета SMTP. Сегодня sendmail имеет возможность отказываться от прав администратора до того, как начинает обрабатывать соединение, исключая эту проблему для тех систем, которые могут поддерживать такую возможность. Стоит заметить, что на тех системах, которые не доставляют почту прямо в почтовые ящики пользователей, sendmail также может запускаться в окружении с запущенной программой chroot, что дает возможность дальнейшей изоляции прав пользователей.

К сожалению, по мере того, как sendmail получал репутацию программы с низкой безопасностью, его начали обвинять в проблемах, которые к нему не имели отношения. Например, один системный администратор дал запись к своей папке /etc для всего мира и затем обвинил sendmail, когда кто-то заменил ему файл /etc/passwd. Подобные инциденты заставили нас серьезно заняться безопасностью, включая явную проверку владельца и режимов файлов и папок, к которым у sendmail был доступ. Эти опции были настолько драконовскими, что мы были вынуждены добавить параметр DontBlameSendmail для возможности их отключения.

Есть и другие аспекты безопасности, которые не относятся к защите адресного пространства самой программы. Например, увеличение количества спама вызвало увеличение сбора адресов. Команды SMTP VRFY и EXPN были специально спроектированы для валидации индивидуальных почтовых адресов и расширения содержимого почтовых списков соответственно. Эти команды впоследствии стали настолько широко использоваться спамерами, что большинство сайтов их полностью отключило. Это прискорбно, так как данная команда иногда использовалась некоторыми анти-спам агентами для валидации подразумеваемых адресов отправки.

Аналогично, защита от вирусов какое-то время рассматривалась как проблема десктоп-приложений, но сейчас проблема выросла до такой степени, что сейчас любой коммерческий агент передачи почты должен иметь антивирусную проверку. Остальные требования безопасности на сегодняшний день включают обязательное шифрование важных данных, защиту от потери данных и принудительное исполнение законных требований, например, для закона о защите информации о здоровье.

Одним из принципов, которым sendmail следовал с самого начала, была надежность – каждое сообщение должно было быть доставлено либо возвращено обратно отправителю. Но проблема joe-jobs (когда атакующий подделывает обратный адрес сообщения, рассматриваемая многими как уязвимость) привела к тому, что многие сайты отключили возможность отправки уведомлений об отклонении доставки. Если проблема с доставкой может быть определена пока еще SMTP-соединение открыто, сервер может сообщить о проблеме неисполнением команды, но после того, как SMTP-соединение закрыто, неверно адресованное сообщение тихо исчезнет.

На сегодняшний день большинство легальных писем доставляются за один раз, поэтому информация о проблемах отправки получается, однако в принципе в мире главной стала позиция «безопасность важнее надежности».

17.7. Эволюция sendmail

Программное обеспечение не выживает в быстро изменяющемся окружении без изменений для приспособления к этому окружению. Появляются новые технологии, которые оказывают влияние на операционные системы, которые в свою очередь влияют на библиотеки и фреймворки, а те – на приложения. Если приложение имеет успех, его начинают использовать в еще более проблемном окружении. Изменения неизбежны; для того, чтобы преуспеть, вам необходимо принять и использовать это. Эта секция описывает наиболее важные изменения в sendmail, которые происходили по мере его развития.

17.7.1. Конфигурирование становится более подробным

Изначально конфигурация sendmail была довольно лаконичной. Например, имена опций и макросы все состояли из одного символа. На это было три причины. Первая, это делало парсинг очень простым (важно в 16-битном окружении). Второе, число опций было не очень большим, поэтому было не сложно придумать мнемонические имена. В-третьих, уже существовал порядок использования одиночных символов как флагов командной строки.

Аналогично, наборы правил преобразования изначально были под номерами, а не под названиями. Это, наверное, было допустимо с небольшим набором правил, но с ростом их числа, стало важно, чтобы у них были мнемонические имена.

По мере того, как окружение в котором работал sendmail становилось более сложным и 16-битное окружение исчезало, небходимость более богатой конфигурации стала очевидной. К счастью, было возможно добавлять изменения с сохранением обратной совместимости. Эти изменения кардинально улучшили читаемость конфигурационного файла.

17.7.2. Большее количество соединений с другими подсистемами: большая интеграция

Когда sendmail был написан, почтовая система была главным образом изолирована от остальной операционной системы. Существовало несколько служб, которые требовали интеграции, например, файлы /etc/passwd и /etc/hosts. Переключатели служб не были еще изобретены, службы каталогов не существовали, конфигурационные файлы были небольшими и поддерживались вручную.

Но все быстро изменилось. Одним из первых нововведений были DNS. Хотя абстракция поиска системного хоста (gethostbyname) работала для поиска IP-адресов, электронная почта должна была использовать другие запросы, такие как MX. Позже IDA sendmail включила функциональность поиска по внешним базам данных, используя файлы dbm(3). Sendmail 8 получил в обновлении общую службу сопоставления данных, которая позволяла взаимодействие с другими типами баз данных и внутренними данными, с которыми нельзя было работать, использовуя простые преобразования (например, деэкранирование в адресах).

Сегодня почтовая система зависит от многих внешних служб, которые в общем не созданы специально для эксклюзивного использования электронной почтой. Это привело к тому, что в коде sendmail появилось больше абстракций. Это также привело к том, что поддержка кода почтовой системы стала сложнее, так как количество изменяющихся элементов прибавилось.

17.7.3. Адаптация к враждебному миру

Sendmail был разработан в мире, который кажется абсолютно чужим по современным стандартам. Пользовательский контингент в ранние периоды жизни сетей состоял главным образом из исследователей, которые были относительно добродушными, несмотря на подчас яростные академические споры. Sendmail отражал тот мир, в котором он был создан, большой акцент был сделан на надежную доставку почты, даже при наличии пользовательских ошибок.

Сегодняшний мир намного более враждебен. Большая доля электронной почты идет от злоумышленников. Задачи агента передачи почты сместились от получения почты до защиты от плохой почты. Фильтрация стала, пожалуй, одной из главных задач для агенты передачи на сегодняшний день. Это потребовало множества изменений в sendmail.

Например, были добавлены многие наборы правил, чтобы разрешить проверку параметров входящих команд SMTP, это дало возможность отлавливать проблему как можно раньше. Гораздо проще отклонить сообщение на стадии чтения конверта, чем после того, как вы направили ресурсы на чтение всего сообщения, и еще более сложно это сделать, когда вы приняли сообщение для доставки. Изначально фильтрация в основном делалась путем принятия сообщения, передачи его фильтрующей программе и затем отправки сообщения другому экземпляру sendmail, если сообщение проходило фильтр (так называемая “сэндвич”-конфигурация). На сегодняшний день это слишком неэффективно.

Аналогично, sendmail прошел путь от довольно простого потребителя TCP/IP соединений до гораздо более сложной системы, способной “просматривать” данные, получаемые из сети, чтобы понять, передает ли отправитель команды до того, как предыдущая команда была принята. Это разрушает некоторые предыдущие абстракции, которые были сделаны для того, чтобы sendmail работал в различных типах сетей. Сегодня потребовалось бы немало работы для адаптации sendmail к сети XNS или DECnet, так как информация о работе с TCP/IP уже встроена в большую часть кода.

Многие функции конфигурирования были добавлены для защиты от враждебного мира, сред них таблицы доступа, список черных дыр в реальном времени, подавление сбора адресов, защита от отказа в обслуживании и фильтрация спама. Это значительно усложнило задачу конфигурирования почтовых систем, но стало абсолютно необходимым для адаптации к сегодняшнему миру.

17.7.4. Внедрение новых технологий

Многие новые стандарты появились за эти годы, они потребовали серьезных изменений в sendmail. Например, добавление TLS (шифрования) потребовало значительных изменений в большей части кода. Конвейерная обработка SMTP (pipelining) потребовала низкоуровневого взаимодействия с потоком TCP/IP для избежания дедлоков. Добавление порта отправки (587) требовало способности слушать несколько входящих портов, включая поддержку различного поведения, в зависимости от входящего порта.

Некоторые ограничения были вызваны скорее обстоятельствами, чем стандартами. Например, добавление интерфейса фильтров milter было прямым ответом на спам. Хотя milter не был опубликованным стандартом, это была новая большая технология.

Во всех случаях эти изменения определенным образом улучшали почтовую систему: либо с точки зрения безопасности, либо за счет производительности или путем добавления новой функциональности. Однако, все изменения имели свою цену, усложняя код и конфигурационный файл.

17.8. Что если бы я делал sendmail сегодня?

Многие вещи на сегодняшний день я бы сделал по-другому. Некоторые было трудно предсказать в то время (например, как спам изменит восприятие почтовой системы, как будут выглядеть современные наборы инструментов и т.д.), а некоторые были очень предсказуемы. В процессе написания sendmail я многое изучил о e-mail, TCP/IP и программировании вообще. Все растут в процессе того, как пишут код.

Но есть множество вещей, которые я бы сделал также, некоторые из них даже несмотря на противоречие с общепринятым мнением.

17.8.1. Что бы я сделал иначе

Наверное моей главной ошибкой с sendmail было то, что я не смог распознать достаточно быстро, насколько важной станет программа. У меня было несколько возможностей подтолкнуть мир в верном направлении, но я ими не воспользовался; в некоторых случаях даже наоборот, я нанес вред, например, не сделав sendmail более строгим к неверным данным, когда это следовало сделать. Я также довольно рано понял, что синтаксис конфигурационного файла нужно улучшить, когда работало только несколько сотен экземпляров sendmail, но решил ничего не менять, потому что не хотел причинять неудобства пользователям. Позже стало ясно, что лучше было бы исправить все раньше, пусть ценой небольшого неудобства, но зато сделать лучше в долгосрочной перспективе.

Синтаксис почтовых ящиков версии 7

Одним из примеров было то, как в почтовых ящиках версии 7 разделялись сообщения. Они использовании строку, начинавшуюся с “From?” (где “?” это ASCII символ пробела, 0х20) для разделения сообщений. Если приходило сообщение, содержавшее слово “From?” в начале строки, программа локальной почты конвертировала ее в “>From?”. После усовершенствования на некоторых системах стала требоваться предварительно пустая строка, но на это нельзя было рассчитывать. И по сей день “>From” появляется в чрезвычайно непредсказуемых местах, которые не совсем связаны с email (но очевидно были обработаны email в какой-то момент времени). В ретроспективе я понимаю, что, наверное мог бы исправить почтовую службу BSD — сделать использование нового синтаксиса. В то время меня бы все проклинали, но в перспективе я спас бы мир от огромных проблем.

Синтаксис и содержимое конфигурационного файла

Возможно, моей самой большой ошибкой в синтаксисе конфигурационного файла было использование таба (HT, 0×09) в правилах преобразования для разделения паттерна от заменяющего текста. В то время я делал подобно тому, как сделано в make, и только годы спустя я узнал, что Стюарт Фельдман, автор make, считал это одной из своих самых больших ошибок. Помимо того, что когда вы смотрите на экран, совсем неочевидно, если именно символ табуляции используется в файле, эти символы не сохраняются после вырезания и вставки на большинстве Windows-систем.

Хотя я считаю, что использование правил преобразования было хорошей идеей (см. ниже), я бы изменил общую структуру конфигурационного файла. Например, я не ожидал необходимость иерархии в конфигурации (например, опций, которые будут задаваться по-разному для различных портов SMTP). В то время, когда создавался конфигурационный файл, не было “стандартных” форматов. Сегодня бы склонялся к тому, чтобы сделать конфигурацию подобно Apache – она чистая, аккуратная и достаточно выразительная – или даже может быть включил язык, подобный Lua.

Когда разрабатывался sendmail, пространство адресов было небольшим и протоколы были еще меняющимися. Большие затраты сил на конфигурационный файл казались хорошей идеей. Сегодня, это выглядит как ошибка: у нас огромное пространство адресов (для агентов передачи почты), а стандарты довольно статичные. Более того, часть “конфигурационного файла” — на самом деле код, который нужно обновлять в новых релизах. Конфигурационный файл .mc исправляет это, но необходимость пересобирать конфигурацию каждый раз, когда обновляется программа, это неудобство. Простым решением для этого было бы иметь два конфигурационных файла, читаемые sendmail’ом: один скрытый и устанавливаемый с каждым новым релизом, а другой открытый, используемый для локальной конфигурации.

Использование инструментов

Сегодня доступно множество новых инструментов, например, для конфигурирования и собирания программ. Инструменты могут помочь в этом, но они также могут быть излишними, затрудняя понимание системы. Например, никогда не нужно использовать грамматику yacc(1), если все что вам нужно — это strtok(3). Но и переизобретать колесо также не является хорошей затеей. В частности, несмотря на некоторые замечания, я все равно точно бы использовал сегодня autoconf.

Обратная совместимость

Если бы у меня была возможность заглянуть в будущее и узнать, насколько вездесущим станет sendmail, я бы не беспокоился о том, что придется прекратить поддержку существующих инсталляций в первые дни разработки. Если существующая практика имеет серьезные проблемы, ее нужно исправлять, а не подстраиваться под нее. Несмотря на это, я бы все равно не сделал бы строгую проверку всех форматов сообщений; некоторые проблемы могут быть легко и безопасно проигнорированы или пропатчены. Например, я бы все равно добавил поле заголовка Message-Id: в сообщения, у которых его не было, но я бы больше склонялся к тому, чтобы отклонять сообщения без поля заголовка From:, а не пытался бы создавать его из информации в конверте.

Внутренние абстракции

Есть несколько внутренних абстракций, к которым я бы не прибегал сегодня, а есть другие, которые бы добавил. Например, я бы не использовал строки с завершающим нулем, вместо этого предпочел бы пары “длина/значение”, несмотря на то, что это означало бы, что большую часть стандартной библиотеки C было бы трудно использовать. Но даже только последствия этого решения для безопасности стоили того. Однако, я бы не пытался создавать систему обработки ошибок на C, вместо этого создал бы последовательную систему статусных кодов, которые бы использовались в коде программы вместо создания процедур, возвращающих null, false или отрицательные числа для представления ошибок.

Я бы точно абстрагировал бы концепцию имен почтовых ящиков от ID пользователя в Unix. В то время, когда я писал sendmail, модель была такова, что сообщения отправлялись только пользователям Unix. Сегодня такое почти не происходит; даже в системах, которые используют эту модель, существуют системные аккаунты, которые никогда не должны получать e-mail.

17.8.2. Что я бы оставил точно так же

Конечно, были и удачные решения…

Syslog

Одним из успешных сайд-проектов sendmail был syslog. В то время, когда писался sendmail, программы, которым нужно было писать информацию в лог, имели для этого специальный файл. Такие файлы были разбросаны по системе. Syslog было трудно написать в то время (UDP еще не существовало, поэтому я использовал так называемые mpx файлы), но это того стоило. Однако, я бы добавил одно существенное изменение: я бы обратил больше внимания на то, чтобы сделать синтаксис логируемых сообщений пригодным для разбора – я все-таки не смог предсказать существование мониторинга логов.

Правила преобразования

Правила преобразования были во многом опорочены, но я бы снова использовал их (хотя, возможно, не во всех случаях, где они используются сейчас). Использование символа табуляции было явной ошибкой, но учитывая ограничения ASCII и синтаксиса почтовых адресов, какой-то символ экранирования все-таки необходим. В общем, концепция использования замены по совпадению с паттерном работала хорошо и была очень гибкой.

Избегать ненужных инструментов

Несмотря на мой комментарий выше о том, что я бы использовал больше внешних инструментов, я бы не стал использовать многие доступные сейчас динамические библиотеки. На мой взгляд многие из них слишком раздуты. Библиотеки нужно выбирать осторожно, балансируя между пользой от повторного использования кода и проблемой использования чересчур мощного инструмента для решения простой проблемы. Как минимум, я бы избегал использования XML в качестве языка конфигурации. Я думаю, что его синтаксис слишком расточителен для таких задач. У XML есть свое место, но сейчас он используется чересчур часто.

Код на C

Некоторые люди предложили, что более естественным языком для реализации были бы Java или C++. Несмотря на хорошо известные проблемы C, я все равно выбрал бы его для реализации. Отчасти это навеяно личными предпочтениями: я знаю C намного лучше, чем Java или C++. Но я также разочарован той небрежностью, с котрой большинство объектно-ориентированных языков относится к распределению памяти. Распределение памяти во многом затрагивает проблемы производительности, которые трудно охарактеризовать. Senmail используется объектно-ориентированные концепции внутри, где это необходимо (например, реализация классов преобразования данных), но на мой взгляд полный переход на объектно-ориентированное программирование было бы очень расточительным и чересчур ограничивающим.

17.9. Заключение

Агент передачи почтовых сообщений sendmail появился в момент невероятного подъема, своего рода “на диком Западе”, существовавшем, когда e-mail не был должным образом продуман, а текущие почтовые стандарты не были еще сформулированы. В последующие 31 год “почтовая проблема” изменялась от просто надежной работы до работы с большими сообщениями и серьезными нагрузками, затем до защиты узлов от спама и вирусов и, наконец, на данный момент – использования в качестве платформы для огромного количества приложений, основанных на использовании e-mail. Sendmail эволюционировал в рабочую лошадку, используемую даже самыми избегающими рисков корпорациями, после того, как электронная почта прошла путь от простой текстовой коммуникации между людьми до части инфраструктуры, использующей мультимедиа и критичной для выполнения задач.

Причины такого успеха не всегда очевидны. Создание небольшой группой частично занятых в проекте разработчиков программы, которая выживает и даже процветает в постоянно меняющемся мире, не может быть выполнено при использовании обычных методологий разработки программного обеспечения. Я надеюсь, что пролил некоторый свет на факторы успеха sendmail.

Ссылки

18.1. Знакомство с SnowFlock

Использование облачных технологий позволяет повысить скорость работы организации. В случае использования физических серверов пользователям приходилось с нетерпением ждать момента, когда представители организации (не торопясь) подтвердят покупку сервера, разместят заказ, доставят сервер и установят операционную систему вместе с наборами приложений. Вместо ожидания завершения работы представителей организации, затягивающегося на недели, пользователь облака самостоятельно контролирует процесс его работы и может создать новый отдельный сервер за считанные минуты.

К сожалению, группы облачных серверов являются разделенными. Используя технологии быстрой установки и модель оплаты за используемые ресурсы, облачные серверы обычно являются элементами групп различного количества аналогично настроенных серверов, выполняющих динамические и масштабируемые задачи, связанные с параллельными вычислениями, обработкой данных или обслуживанием веб-страниц. Так как серверы в этих группах периодически используют для загрузки один и тот же постоянный образ, коммерческие облачные системы не предоставляют полноценного решения для проведения вычислений в зависимости от потребностей в мощностях. После создания сервера пользователь облака должен также установить его принадлежность к кластеру и провести дополнительные работы, связанные с добавлением новых серверов.

SnowFlock решает эти проблемы с помощью технологии клонирования виртуальных машин (VM Cloning), представленной вызовом API. Аналогично тому, как код приложения вызывает службы ОС с помощью интерфейса системных вызовов при повседневной работе, на сегодняшний день он также может вызывать службы облачных сервисов, используя подобный интерфейс. С помощью технологии клонирования виртуальных машин операции резервирования ресурсов, управления кластером, а также логика приложения могут быть объединены программно и рассматриваться как отдельная логическая операция.

Использование вызова для клонирования виртуальных машин позволяет получить идентичные на момент клонирования копии виртуальной машины родительского сервера на множестве облачных серверов. Логически клонированные виртуальные машины наследуют данные состояния родительской виртуальной машины, включая кэши уровня операционной системы и приложений. Более того, клонированные виртуальные машины автоматически добавляются во внутреннюю локальную сеть, таким образом входя в состав динамически масштабируемого кластера. Новые вычислительные ресурсы, представленные идентичными виртуальными машинами, могут быть созданы мгновенно, а также могут динамически загружаться работой в случае необходимости.

На практике метод клонирования виртуальных машин зарекомендовал себя как успешно функционирующая, эффективная и быстрая технология. В данной главе мы опишем метод эффективной интеграции реализации технологии клонирования виртуальных машин SnowFlock в различные программные модели и фреймворки, метод реализации данной технологи с учетом минимизации затрат ресурсов программным окружением и системой, а также метод создания множества новых виртуальных машин в течение пяти секунд или меньшего промежутка времени.

Учитывая наличие API для программного контроля за клонированием виртуальных машин и наличие биндингов для языков C, C++, Python и Java, SnowFlock является весьма гибким и многоцелевым программным компонентом. Мы успешно использовали SnowFlock в реализации прототипов нескольких значительно отличающихся друг от друга систем. В условиях параллельных вычислений мы достигли превосходных результатов, клонируя работающие виртуальные машины, которые совместно распределяли нагрузку между множеством физических узлов. При работе с приложениями для параллельных вычислений мы использовали интерфейс передачи сообщений (Message Passing Interface - MPI) и запускали их на кластере выделенных серверов, при этом модифицировав метод запуска интерфейса передачи сообщений для того, чтобы повысить производительность немодифицированных приложений и снизить затраты ресурсов, предоставляя кластер из только что клонированных виртуальных по запросу при каждом запуске. Наконец, в значительно отличающемся случае, мы использовали SnowFlock для повышения эффективности и производительности elastic-серверов. На сегодняшний день облачные elastic-серверы осуществляют холодную загрузку новых рабочих серверов при необходимости обработки повышенных нагрузок. Применяя вместо этого технологию клонирования виртуальных машин, SnowFlock позволяет вводить в строй новые рабочие серверы в 20 раз быстрее, а так как клонированные виртуальные машины наследуют рабочие буферы от родительской виртуальной машины, они быстрее достигают своей пиковой производительности.

18.2. Технология клонирования виртуальных машин

Как становится ясно из названия, клонированные виртуальные машины (практически) полностью идентичны родительской виртуальной машине. Существуют некоторые незначительные, но необходимые различия для решения таких проблем, как конфликты MAC-адресов, но мы вернемся к рассмотрению этого вопроса позднее. Для создания клонированной виртуальной машины данные локального диска и оперативной памяти должны быть доступны в полном объеме, что заставляет нас рассмотреть первое важное архитектурное решение: должны ли мы скопировать эти данные незамедлительно или по мере необходимости?

Простейшим способом реализации механизма клонирования виртуальной машины является реализации стандартной возможности "миграции" виртуальной машины. Обычно миграция осуществляется в том случае, когда работающая виртуальная машина должна быть перенесена на другой узел, например, в том случае, когда узел находится в перегруженном состоянии или может быть отключен для технического обслуживания. Так как виртуальная машина является программным решением, ее состояние может быть сохранено в файле данных, после чего этот файл может быть скопирован на новый, более подходящий узел, на котором работа виртуальной машины продолжится после кратковременного перерыва. Для выполнения этой задачи существующие мониторы виртуальных машин создают файл, содержащий "данные контрольной точки" виртуальной машины, включающие в себя локальную файловую систему, образ памяти, регистры виртуального центрального процессора (VCPU), и.т.д. При миграции новая загруженная копия подменяет оригинал, но процесс может быть модифицирован таким образом, чтобы система клонировалась, а работа оригинальной системы не прерывалась. В ходе этого "активного" процесса все данные виртуальной машины передаются незамедлительно, что позволяет достичь лучшей начальной производительности. Недостатком активной репликации является тот факт, что сложный процесс копирования всех данных виртуальной машины должен быть завершен перед началом работы виртуальной машины, что значительно замедляет инициализацию.

Диаметрально противоположным способом, реализованным в SnowFlock, является "отложенная" репликация данных. Вместо копирования всех данных, которые могут понадобиться виртуальной машине, SnowFlock передает только необходимые для запуска данные, а остальные данные передаются позднее, когда они понадобятся клонированной виртуальной машине. Этот способ имеет два преимущества. Во-первых, он позволяет минимизировать задержку, выполняя незамедлительно настолько малое количество работы, насколько это возможно. Во-вторых, он позволяет повысить эффективность работы, копируя только те данные, которые действительно используются клонированной виртуальной машиной. Повышение эффективности работы клонированной виртуальной машины, конечно же, зависит от выполняемой задачи, но только малая часть приложений использует каждую страницу памяти и каждый файл из локальной файловой системы.

Однако, достоинства отложенной репликации не обуславливают отсутствие недостатков. Так как передача данных откладывается до последнего, клонированная виртуальная машина будет ожидать их приема до момента продолжения работы. Эта ситуация аналогична сохранению страниц памяти в раздел подкачки диска в многозадачной системе: приложения блокируются, ожидая получения данных из источника с длительной задержкой. В случае SnowFlock блокировка в некоторой степени снижает производительность клонированной виртуальной машины; степень снижения производительности зависит от приложения. Для высокопроизводительных вычислительных приложений мы не обнаружили значительного снижения производительности, но производительность клонированного сервера базы данных в первое время будет низкой. Следует учесть, что этот эффект кратковременен: он длится несколько минут, после чего все необходимые данные передаются и производительность клонированной виртуальной машины сравнивается с производительностью родительской виртуальной машины.

Кстати, если вы располагаете большим опытом работы с виртуальными машинами, вас наверняка интересует вопрос о том, будут ли полезны оптимизации, используемые при миграции "в реальном времени" в данном случае. Процесс миграции в реальном времени оптимизируется с целью сокращения интервала между отключением оригинальной виртуальной машины и возобновлением выполнения работы с помощью ее копии. Для этого монитор виртуальной машины (VMM) предварительно копирует данные виртуальной машины в то время, как оригинальная машина все еще работает, поэтому после завершения ее работы необходимо передать только недавно измененные страницы памяти. Эта техника не влияет на интервал между запросом миграции и началом выполнения работы копией виртуальной машины, а значит, не способна уменьшить задержку запуска виртуальной машины при "активном" клонировании.

18.3. Подход SnowFlock

SnowFlock реализует операцию клонирования виртуальных машин с помощью примитива под названием "VM Fork", который аналогичен стандартной функции fork из состава Unix, но имеет несколько важных отличий. Во-первых, вместо дублирования единственного процесса, VM Fork дублирует всю виртуальную машину, включая всю память, все процессы и виртуальные устройства, а также локальную файловую систему. Во-вторых, вместо создания одной копии процесса, выполняющегося на том же физическом узле, VM Fork может запустить одномоментно множество копий виртуальных машин в параллельном режиме. Наконец, виртуальные машины могут быть запущены на отдельных физических серверах, позволяя вам быстро расширить возможности облака в случае необходимости.

Данные концепции являются ключевыми для SnowFlock:

Мы реализовали SnowFlock, используя систему виртуализации Xen, поэтому для лучшего понимания полезно привести специфическую для Xen терминологию. В окружении Xen мониторы виртуальных машин называются гипервизороми, а сами виртуальные машины - доменами. На каждой физической машине (узле) существует привилегированный домен, называемый "domain 0" (dom0) и имеющий полный доступ к узлу и его физическим устройствам, который может использоваться для контроля дополнительных гостевых или "пользовательских" виртуальных машин, называемых "domain U" (domU).

В общих чертах, SnowFlock содержит набор модификаций для гипервизора Xen, позволяющих ему благополучно восстанавливать работу в случае доступа к отсутствующим ресурсам, набор поддерживающих работу процессов и систем, которые выполняются в рамках домена dom0 и совместно передают недостающие данные состояния виртуальным машинам, а также некоторые дополнительные модификации для операционных систем, выполняющихся в клонированных виртуальных машинах. Существует шесть основных компонентов SnowFlock.

Архитектура системы репликации виртуальных машин в SnowFlock
Рисунок 18.1: Архитектура системы репликации виртуальных машин в SnowFlock

Образно говоря, Рисунок 18.1 отражает процесс клонирования виртуальной машины, обозначая четыре основных шага: (1) остановка родительской виртуальной машины для создания архитектурного дескриптора; (2) распространение этого дескриптора среди всех целевых узлов; (3) создание клонированных виртуальных машин, практически не располагающих данными состояния, а также (4) распространение данных состояния по запросам. Данный рисунок также отражает использование системы многоадресного распространения данных mcdist и предупреждение запросов с помощью механизмов оптимизации на стороне гостевых виртуальных машин.

Если вы желаете испытать SnowFlock в рабочих условиях, вы можете получить данный программный продукт двумя способами. Документация и открытый исходный код из состава оригинального исследовательского проекта SnowFlock Университета Торонто находятся в свободном доступе1. Если вы предпочитаете версию, используемую в индустриальных проектах, бесплатные лицензии для некоммерческого использования распространяются компанией GridCentric Inc.2 Так как SnowFlock содержит модификации для гипервизора и требует доступа к домену dom0, для установки SnowFlock требуется привилегированный доступ к машинам. По этой причине вам придется использовать собственное аппаратное обеспечение и вы не сможете испытать данный продукт, являясь пользователем такого коммерческого облачного окружения, как Amazon EC2.

В нескольких следующих разделах мы опишем различные программные компоненты, взаимодействующие с целью реализации быстрого и эффективного процесса клонирования виртуальных машин. Все компоненты, которые мы опишем, взаимодействуют так, как показано на Рисунке 18.2.

Программные компоненты из состава SnowFlock
Рисунок 18.2: Программные компоненты из состава SnowFlock

18.4. Архитектурный дескриптор виртуальной машины

Ключевым архитектурным решением, примененным в SnowFlock, является возможность выполнения отложенной репликации данных состояния виртуальной машины во время ее работы. Другими словами, копирование памяти виртуальной машины является операцией позднего связывания, предоставляющей множество возможностей для оптимизации.

Первым шагом для проведения в жизнь этого архитектурного решения является генерация архитектурного дескриптора для данных состояния виртуальной машины. Это данные, которые будут использованы для создания клонированных виртуальных машин. Дескриптор содержит абсолютный минимум необходимых для создания виртуальной машины и планирования ее работы данных. Как становится ясно из названия, этот абсолютный минимум данных состоит из структур данных, необходимых для удовлетворения требований используемой архитектуры. В случае SnowFlock под архитектурными требованиями понимают комбинацию требований процессора x86 и требований технологии Xen. Таким образом, архитектурный дескриптор содержит такие структуры данных, как таблицы страниц, виртуальные регистры, метаданные устройств, метки времени, и.т.д. Интересующемуся читателю следует обратиться к [LCWB+11] для ознакомления с более подробным описанием содержимого архитектурного дескриптора.

Архитектурный дескриптор имеет три важных свойства: Во-первых, он может быть создан за короткий промежуток времени; нередко это промежуток времени длительностью 200 миллисекунд. Во-вторых он имеет малый размер, обычно на три порядка меньше, чем объем памяти, зарезервированной во время работы родительской виртуальной машины (1 МБ для 1 ГБ памяти виртуальной машины). И в-третьих, клонированная виртуальная машина может быть создана из дескриптора менее чем за секунду (обычно за 800 миллисекунд).

Последствием, конечно же, является тот факт, что клонированные виртуальные машины не обладают большинством своих данных состояния в момент их создания из дескриптора. В следующих разделах мы опишем способ решения этой проблемы, а также способ реализации возможностей оптимизации, предоставляемых данным подходом.

18.5. Компоненты на стороне родительской виртуальной машины

Как только происходит клонирование виртуальной машины, она становится родительской для дочерних или клонированных виртуальных машин. Как и все ответственные родители, она должна следить за благополучием своих потомков. Она делает это с помощью ряда служб, посредством которых производится доставка данных состояния памяти и диска клонированным виртуальным машинам по запросу.

18.5.1. Процесс сервера памяти

Когда архитектурный дескриптор создан, виртуальная машина прерывает свою работу. Таким образом фиксируются данные состояния памяти; перед приостановкой работы виртуальной машины и планированием этого действия, внутренние драйверы операционной системы сохраняют ее состояние, из которого клонированные виртуальные машины смогут соединиться с внешним миром, работая в новом окружении. Мы использовали это состояние для создания "сервера памяти", или службы memserver.

Сервер памяти будет предоставлять всем клонированным виртуальным машинам участки памяти, которые они потребуют от родительской виртуальной машины. Единица распространения данных памяти идентична странице памяти архитектуры x86 (4 килобайта). В простейшем случае сервер памяти ожидает запросов страниц от клонированных виртуальных машин и обслуживает одну страницу памяти и одну виртуальную машину в каждый момент работы.

Однако, эта память используется родительской виртуальной машиной, которая должна продолжить свою работу. Если мы позволим родительской виртуальной машине просто модифицировать эту память, мы будем предоставлять поврежденные участки памяти клонированным виртуальным машинам: переданный участок будет отличаться от участка во время клонирования, поэтому процесс работы клонированных виртуальных машин будет серьезно нарушен. Используя терминологию разработчиков ядра, можно сказать, что это верный способ для создания трассировок стека.

Для преодоления данной проблемы может использоваться классический для операционных систем подход: метод копирования при записи или копируемая при записи память. При поддержке гипервизора Xen мы можем убрать привилегии записи со всех страниц памяти родительской виртуальной машины. Когда родительская виртуальная машина попытается модифицировать одну из страниц памяти, будет сгенерирована аппаратная ошибка доступа к ней. Система Xen располагает информацией о причинах данной ошибки и создаст копию страницы. Родительская виртуальная машина получит разрешение на перезапись данных оригинальной страницы и продолжит выполнение в то время, как сервер памяти будет использовать копию страницы, доступную только для чтения. Таким образом, состояние памяти с момента клонирования остается неизменным, работа клонированных виртуальных машин не нарушается и родительская виртуальная машина может продолжать свою работу. Дополнительные затраты ресурсов за счет использования копирования при записи минимальны: аналогичные механизмы используются ядром Linux, например, при создании новых процессов.

18.5.2. Многоадресное распространение данных с помощью службы mcdist

Клонированные виртуальные машины обычно страдают экзистенциальным синдромом, известным под названием "детерминизм судьбы". Мы создаем клонированные виртуальные машины с одной и той же целью: например, для выравнивания цепочек X-хромосом ДНК относительно сегмента цепочки Y-хромосом из базы данных. Более того, мы создаем множества клонированных виртуальных машин для выполнения одинаковых действий, возможно, выравнивания одних и тех же цепочек X-хромосом относительно различных сегментов цепочек из базы данных или выравнивания различных цепочек относительно одного и того же сегмента цепочки Y-хромосом. В данном случае большая часть попыток доступа к памяти клонированными виртуальными машинами будет идентично локализована: они исполняют одинаковый код и используют большие объемы сходных данных.

Для эксплуатации идентичной локализации требуемых страниц памяти в состав SnowFlock введена наша реализация многоадресной системы распространения данных mcdist. Служба mcdist использует многоадресную передачу по протоколу IP для одновременной доставки пакетов данных множеству принимающих сторон. При ее функционировании используются преимущества параллельной работы аппаратной реализации сетевого интерфейса с целью снижения нагрузки на сервер памяти. После отправки ответа на первый запрос страницы памяти всем клонированным виртуальным машинам, каждый запрос от клонированной виртуальной машины позволяет сохранить страницу другим клонированным виртуальным машинам из-за их идентичных методов работы с памятью.

В отличие от других систем многоадресной передачи, mcdist не является надежной, не доставляет пакеты в заданной последовательности и не позволяет атомарно доставить ответ всем принимающим сторонам. Многоадресная передача введена исключительно как мера оптимизации и надежная доставка страницы должна быть осуществлена исключительно той клонированной виртуальной машине, которая явно ее запросила. Таким образом, архитектура становится элегантной и простой: сервер просто осуществляет многоадресную передачу ответов, а клиенты устанавливают период времени ожидания и если они не получают ответ на свой запрос в течение этого периода, просто повторяют запрос.

Три специфические для SnowFlock оптимизации, проведенные в рамках службы mcdist:

18.5.3. Виртуальный диск

Клонированные с помощью SnowFlock виртуальные машины, ввиду их короткого времени жизни и "детерменизма судьбы", редко используют диск. На виртуальном диске для виртуальной машины SnowFlock размещается корневой раздел с бинарными файлами, библиотеками и файлами конфигурации. Обработка больших объемов данных производится с использованием таких подходящих для этого файловых систем, как HDFS или PVFS. Следовательно, когда клонированные виртуальные машины SnowFlock принимают решение о необходимости чтения данных с корневого раздела диска, их запросы обычно обслуживаются кэшем страниц файловых систем из состава ядра.

Упомянув об этом, нам все еще необходимо предоставить доступ к виртуальному диску для клонированных виртуальных машин в тех редких случаях, когда он требуется. Мы пошли по пути наименьшего сопротивления и реализовали архитектуру репликации данных диска аналогично архитектуре репликации данных памяти. Во-первых, состояние диска является неизменным во время клонирования. Родительская виртуальная машина продолжает использовать свой диск, осуществляя копирование при записи: данные из записываемых страниц сохраняются в отдельном хранилище и содержимое диска, передаваемое клонированным виртуальным машинам, остается неизменным. Во-вторых, данные состояния диска распространяются с использованием механизма многоадресной передачи между всеми клонированными виртуальными машинами с помощью службы mcdist, причем единицей распространения данных является все та же страница размером в 4 КБ и при передаче используются те же механизмы оптимизации на основе временной локализации. В-третьих, репликация данных состояния диска для клонированной виртуальной машины достаточно проста: данные хранятся в обычном файле, который удаляется сразу же после завершения работы клонированной виртуальной машины.

18.6. Компоненты на стороне клонированных виртуальных машин

Клонированные виртуальные машины представляют из себя пустые программные оболочки сразу после создания с помощью архитектурного дескриптора, поэтому как и в случае людей им требуется значительная помощь со стороны родителей во время роста: дочерние виртуальные машины покидают домашнее пространство, но всегда обращаются к нему в случаях, когда обнаруживают факт отсутствия каких-либо необходимых им данных, прося родительские виртуальные машины отправить им эти данные незамедлительно.

18.6.1. Процесс memtap

Присоединяясь к каждой клонированной виртуальной машине после ее создания, процесс memtap является жизненно важным для нее программным компонентом. Он отображает всю память клонированной виртуальной машины и заполняет ее данными в случае необходимости. Для реализации некоторых ключевых функций используется гипервизор Xen: права доступа к страницам памяти клонированных виртуальных машин не устанавливаются, поэтому аппаратные ошибки, вызванные первыми попытками доступа к страницам, перенаправляются гипервизором процессу memtap.

В простейшем случае процесс memtap просто запрашивает страницу, при обращении к которой произошла ошибка, у сервера памяти, но возможны и более сложные сценарии работы. Во-первых, вспомогательные процессы memtap используют службу mcdist. Это значит, что в любой момент времени любая страница может быть доставлена только из-за того, что она была запрошена другой виртуальной машиной - в этом заключается красота асинхронного распространения данных. Во-вторых, мы позволяем виртуальным машинам SnowFlock быть многопроцессорными виртуальными машинами. В ином случае работа с ними не доставляла бы удовольствия. Это значит, что множество ошибок должно обрабатываться параллельно, возможно даже для одной и той же страницы. В-третьих, в поздних версиях memtap вспомогательные процессы могут явно получать данные для группы страниц, которые могут быть доставлены в любом порядке ввиду отсутствия гарантий доставки данных в определенном порядке сервером mcdist. Любые из этих факторов ведут к кошмару параллелизма, а нам необходимо учитывать все эти факторы.

Архитектура memtap разрабатывалась с учетом главенствующей роли битовой маски наличия страниц. Битовая маска создается и инициализируется в момент обработки архитектурного дескриптора с целью создания клонированной виртуальной машины. Битовая маска является плоским массивом размера, соответствующего допустимому количеству страниц памяти виртуальной машины. Процессоры Intel поддерживают полезные атомарные инструкции для осуществления битовых преобразований: установка значения бита или проверка значения и его установка может происходить гарантированно атомарно по отношению к другим процессорам системы. Это обстоятельство позволяет нам избежать блокировок в большинстве случаев и, таким образом, предоставить доступ к битовой маске различным объектам из различных защищенных областей: гипервизору Xen, процессу memtap и самому гостевому ядру клонированной виртуальной машины.

Когда Xen обрабатывает аппаратную ошибку при первом доступе к странице, он использует битовую маску для принятия решения о том, нужно ли взаимодействовать с memtap. Он также использует битовую маску для включения в очередь нескольких виртуальных процессоров как зависящих от одной отсутствующей страницы. Процесс memtap добавляет страницы в буфер в той последовательности, в которой они прибывают. Когда буфер заполняется или прибывает явно запрошенная страница, работа виртуальной машины приостанавливается и битовая маска используется для удаления из буфера всех доставленных дублей страниц, которые уже присутствовали. После этого все оставшиеся необходимые страницы копируются в память виртуальной машины и устанавливаются соответствующие биты в битовой маске.

18.6.2. Оптимально работающие клонированные виртуальные машины избегают чрезмерных запросов

Мы только что упомянули о том, что битовая маска наличия страниц доступна для ядра, работающего в клонированной виртуальной машине, а также о том, что для ее модификации не требуется блокировок. Это позволяет клонированным виртуальным машинам использовать мощную "возможность оптимизации": они могут предотвратить запросы страниц, модифицировав битовую маску и указав, что заданные страницы присутствуют. Эта возможность особенно полезна в плане повышения производительности и безопасна в тех случаях, когда страницы полностью перезаписываются перед использованием.

Ситуации, в которых можно избежать запросов страниц, достаточно распространены. Все операции резервирования памяти в ядре (с использованием функций vmalloc, kzalloc, get_free_page, функции пространства пользователя brk и других подобных функций) в конце концов осуществляются с помощью механизма резервирования страниц ядра. Обычно резервирование страниц инициируется промежуточными системами резервирования, которые работают с небольшими участками памяти: системой распределения памяти slab, системой распределения памяти для процессов пространства пользователя malloc из состава glibc, и.т.д. Однако, в случаях явного или неявного резервирования памяти одно ключевое семантическое заключение всегда верно: никто не беспокоится о содержимом страницы памяти, так как ее содержимое будет перезаписано произвольным образом. Зачем тогда получать содержимое такой страницы? Для этого нет причин и эмпирический опыт говорит о том, что отказ от получения таких страниц чрезвычайно выгоден.

18.7. Интерфейс приложений для клонирования виртуальных машин

До этого момента мы рассматривали особенности внутренней реализации процесса эффективного клонирования виртуальных машин. Как бы не были интересны описанные системы, нам необходимо обратить внимание на программные компоненты, использующие эти системы: приложения.

18.7.1. Реализация API

Функции клонирования виртуальных машин доступны приложениям посредством простого API SnowFlock, схематично изображенного на Рисунке 18.1. Клонирование, в основном, является двухэтапным процессом. Вначале вы осуществляете запрос резервирования ресурсов для клонированных виртуальных машин, при этом, в зависимости от используемых системных политик, может быть зарезервирован объем ресурсов меньший, чем запрошенный. После этого вы сможете использовать зарезервированные ресурсы для клонирования виртуальной машины. Ключевым условием является то, что ваша виртуальная машина должна выполнять одну операцию. Клонирование виртуальных машин применяется в случаях их использования для выполнения единственного приложения, например, поддержки работы веб-сервера или компонента фермы рендеринга. Если вы используете окружение рабочего стола, в котором множество приложений параллельно вызывают функции клонирования виртуальных машин, вы на пути к хаосу.

sf_request_ticket(n) Запрашивает резервирование системных ресурсов для клонирования n виртуальных машин. Возвращает структуру ticket, описывающую ресурсы для m≤n клонированных виртуальных машин.
sf_clone(ticket) Клонирует виртуальную машину, используя структуру ticket, описывающую зарезервированные ресурсы. Возвращает идентификатор клонированной виртуальной машины ID, 0≤ID≤m.
sf_checkpoint_parent() Подготавливает неизменную контрольную точку C для родительской виртуальной машины, которая может быть использована для клонирования этой машины по прошествии сколь угодно долгого периода времени.
sf_create_clones(C, ticket) Аналогична функции sf_clone, но использует контрольную точку C. Клонированные виртуальные машины начнут работу с того момента, когда была вызвана соответствующая функция sf_checkpoint_parent().
sf_exit() Завершает работу дочерней виртуальной машины (1≤ID≤m).
sf_join(ticket) Блокирует родительскую виртуальную машину (ID=0) до того момента, как все дочерние виртуальные машины, описанные в структуре ticket, достигнут вызова sf_exit. В этот момент все дочерние виртуальные машины прекратят свою работу и структура ticket станет недействительной.
sf_kill(ticket) Используется только для родительской виртуальной машины, делает недействительной структуру ticket и немедленно завершает работу всех дочерних виртуальных машин.
Таблица 18.1: API клонирования виртуальных машин SnowFlock

API просто формирует сообщения и передает их XenStore, интерфейсу на основе разделяемой памяти с низкой пропускной способностью, используемому Xen для передачи управляющих сообщений. Локальный демон SnowFlock (SnowFlock Local Daemon - SFLD) запускает гипервизор и ожидает запросов. Сообщения извлекаются из очереди, после чего выполняются переданные в них команды и отправляются ответы.

Программы могут контролировать процесс клонирования виртуальных машин напрямую с помощью API, доступного для языков программирования C, C++, Python и Java. Сценарии оболочки, связанные с выполнением программы, могут использовать поставляемые инструменты с интерфейсом командной строки вместо данного API. Такие фреймворки для параллельных вычислений, как MPI, могут включать API в свой состав: программы на основе MPI смогут использовать SnowFlock, не располагая фактами об этом и без модификации исходного кода. Балансировщики нагрузки, используемые перед веб-серверами или серверами приложений, могут использовать API для клонирования подконтрольных им серверов.

Локальные демоны SnowFlock управляют исполнением запросов клонирования виртуальных машин. Они создают и отправляют архитектурные дескрипторы, создают клонированные виртуальные машины, запускают серверы диска и памяти и запускают вспомогательные процессы memtap. Они представляют собой миниатюрную распределенную систему, ответственную за управление виртуальными машинами в рамках физического кластера.

Локальные демоны SnowFlock отправляют данные о резервировании ресурсов центральному мастер-демону SnowFlock. Мастер-демон SnowFlock просто взаимодействует с соответствующим программным обеспечением для управления кластером. Мы не видим необходимости повторно изобретать колесо в данном случае, поэтому доверяем выполнение задач резервирования ресурсов, установки квот, политик, и.т.д., такому предназначенному для этих целей программному обеспечению, как Sun Grid Engine или Platform EGO.

18.7.2. Необходимые изменения

После клонирования большинство процессов виртуальных машин не информированы о том, что они уже выполняются не в родительской виртуальной машине, а в ее копии. В большинстве случаев данный подход просто работает и не вызывает нареканий. Все-таки главной задачей операционной системы является изоляция приложений от специфических низкоуровневых данных, таких, как сетевая идентичность. Все же, беспрепятственный процесс клонирования требует использования дополнительного набора механизмов. Основной проблемой является управление сетевой идентичностью клонированной виртуальной системы; для того, чтобы избежать конфликтов и путаницы, мы должны произвести незначительные модификации данных в ходе процесса клонирования. Также из-за того, что эти модификации могут повлечь за собой высокоуровневые адаптации, должно быть предусмотрено включение в цепочку обработчиков для того, чтобы пользователь имел возможность настроить выполнение любых необходимых задач, таких, как (пере)монтирование сетевых файловых систем, которые зависят от сетевой идентичности клонированной виртуальной машины.

Клонированные виртуальные машины появляются в мире, который в большей степени не ожидает их появления. Родительская виртуальная машина является частью сети, наиболее вероятно управляемой DHCP-сервером или любым другим из несметного числа способов, которые используют системные администраторы в своей работе. Вместо предположения о неминуемо негибком сценарии работы, мы поместим родительскую и все клонированные виртуальные машины в их отдельную частную виртуальную сеть. Клонированные виртуальные машины от одного родителя связаны с уникальными идентификаторами и их IP-адреса в этой частной сети автоматически устанавливаются во время клонирования с помощью функции, зависящей от идентификатора. Это гарантирует то, что вмешательство системного администратора не является необходимым, а также никогда не возникнет коллизий IP-адресов.

Смена IP-адресов производится непосредственно с помощью включенной в цепочку обработчиков драйвера виртуальной сети соответствующей функции. При этом мы также используем драйвер для автоматической генерации синтетических ответов DHCP. Таким образом, независимо от вашего выбора дистрибутива, ваш виртуальный сетевой интерфейс позволит передать корректные данные IP гостевой операционной системе, даже в случае перезагрузки.

Для того, чтобы избежать ситуации, при которой различные родительские виртуальные машины проникают в чужие частные виртуальные сети, а также происходят внутренние DDoS-атаки, виртуальные сети для клонированных виртуальных машин разделяются на канальном уровне (уровне 2 OSI). Мы заимствуем диапазон уникальных для организаций MAC-адресов (Ethernet MAC OUI)3 и назначим их клонированным виртуальным машинам. Назначение уникального MAC-адреса будет зависеть от родительской виртуальной машины. Аналогично тому, как IP-адрес виртуальной машины устанавливается в зависимости от ее идентификатора, ее MAC-адрес, не относящийся к полученному диапазону адресов, также зависит от идентификатора. Драйвер виртуальной сети преобразует MAC-адрес виртуальной машины, предполагая его зависимость от идентификатора, и отфильтровывает весь трафик, движущийся в обоих направлениях в частной виртуальной сети с использованием другого диапазона MAC-адресов. Данное разделение трафика эквивалентно разделению, реализуемому при помощи ebtables, но при этом реализуется значительно проще.

Реализация режима обмена данными виртуальных машин друг с другом является замечательной идеей, но ее не достаточно. Иногда мы хотим, чтобы наши клонированные виртуальные машины отвечали на HTTP-запросы из Интернет или подключались к публичным репозиториям данных. Мы снабдим любое множество родительских и клонированных виртуальных машин отдельной виртуальной машиной для маршрутизации. Эта небольшая виртуальная машина работает как межсетевой экран, управляет пропускной способностью канала и осуществляет преобразование сетевых адресов для трафика, направленного от клонированных виртуальных машин в Интернет. Она также ограничивает входящие соединения к родительской виртуальной машине и к портам с известными номерами. Виртуальная машина для маршрутизации не затрачивает большое количество ресурсов, но является единой точкой централизации для сетевого трафика, что может серьезно ограничить масштабируемость. Одни и те же правила функционирования сети могут быть распределены и применены к каждому узлу, на котором работает клонированная виртуальная машина. Мы пока не выпустили этот экспериментальный патч.

Локальные демоны SnowFlock назначают идентификаторы виртуальных машин и передают драйверам виртуальной сети данные, на основе которых они смогут провести настройку: внутренние MAC- и IP-адреса, директивы DHCP, координаты виртуальной машины для маршрутизации, правила фильтрации трафика, и.т.д.

18.8. Заключение

Изменив принцип работы гипервизора Xen и осуществляя отложенную передачу данных состояния виртуальных машин, SnowFlock способен создавать множество работающих клонированных виртуальных машин в течение нескольких секунд. Таким образом, процесс клонирования виртуальных машин при помощи SnowFlock происходит незамедлительно в реальном времени - это повышает удобство использования облачной инфраструктуры, автоматизируя процессы обслуживания кластера и предоставляя приложениям обширные возможности для контроля над ресурсами облака. Также SnowFlock улучшает отзывчивость облака, ускоряя скорость запуска виртуальных машин в 20 раз и повышая производительность большинства созданных виртуальных машин путем копирования используемых операционной системой и приложениями данных кэшей из памяти. Ключевыми возможностями SnowFlock, обуславливающими высокую производительность, являются эвристические методы оптимизации, позволяющие избежать ненужных запросов страниц памяти, и система многоадресного распространения данных, позволяющая совместно получать данные состояния множеству клонированных виртуальных машин. Все, что потребовалось для реализации данного продуманного приложения - это несколько проверенных технологий, ловкость рук и щедрая помощь системы отладки промышленного уровня.

При работе над SnowFlock мы получили два важных урока. Первым уроком является важность обычно недооцениваемого KISS-подхода. Мы ожидали, что реализация технологии снижения интенсивности потока запросов страниц памяти клонированной виртуальной машиной после запуска окажется достаточно сложной. К нашему особенному удивлению, необходимости в ней не обнаружилось. Система работает достаточно хорошо при различных нагрузках с учетом соблюдения единственного принципа: следует доставлять данные памяти тогда, когда они требуются. Другим примером важности простоты реализации является битовая маска наличия страниц памяти. Простая структура данных с применением понятных атомарных семантик доступа значительно упрощает решение ужасной проблемы совместного доступа, которая заключается в соревновании множества виртуальных центральных процессоров за обновление страниц в условиях их асинхронной доставки с помощью многоадресной технологии распространения.

Вторым уроком была важная роль масштабирования. Другими словами, будьте готовы к тому, что работа вашей системы будет нарушена и новые узкие места будут обнаруживаться каждый раз, когда вы увеличиваете нагрузку на систему в два раза. Данный урок тесно связан с предыдущим: простые и элегантные решения отлично масштабируются и не таят в себе нежелательных сюрпризов и повышений нагрузок. Основным примером в данном случае является система mcdist. При тестировании масштабирования в большом диапазоне механизм распространения страниц с использованием протокола TCP/IP в значительной степени замедляет работу при обслуживании сотен клонированных виртуальных машин. Система mcdist успешна благодаря очень проработанному и точно обозначенному распределению задач: клиенты беспокоятся только о доставке своих страниц; сервер беспокоится только о глобальном управлении потоком. Поддержка mcdist в виде небольшого и простого приложения, обуславливает отличную возможность масштабирования SnowFlock.

Если вам хочется узнать больше, вы можете посетить сайт Университета Торонто1, чтобы ознакомиться с академическими отчетами и открытым под лицензией GPLv2 исходным кодом, а также сайт GridCentric4 для получения реализации промышленного уровня.

Сноски

  1. http://sysweb.cs.toronto.edu/projects/1
  2. http://www.gridcentriclabs.com/architecture-of-open-source-applications
  3. Уникальный идентификатор организации (OUI, Organizational Unique ID) является диапазоном MAC-адресов, закрепленным за производителем оборудования.
  4. http://www.gridcentriclabs.com/

19.1. WikiCalc

Первая версия WikiCalc (рис. 19.1) имела несколько особенностей, которые в то время отличали ее от других таблиц:

Рис.19.1: Интерфейс WikiCalc 1.0

Рис.19.2: Компоненты WikiCalc

Рис.19.3: Поток данных в WikiCalc

Внутренняя архитектура (рис.19.2) и информационные потоки (рис.19.3) были преднамеренно простыми, но, тем не менее, мощными. Возможность составлять мастер таблицу из нескольких меньших таблиц оказалась особенно удобной. Например, представьте себе ситуацию, когда каждый продавец хранит показатели продаж на странице электронной таблицы. Затем каждый менеджер сводит их показатели в региональную таблицу, а вице-президент по продажам затем сводит региональные показатели в таблицу верхнего уровня.

Каждый раз, когда одна из отдельных таблиц обновляется, все таблицы более высокого уровня могут отобразить обновление. Если для кого-нибудь нужны подробности, то нужно будет просто щелкнуть мышкой и посмотреть в таблицу, лежащую за текущей таблицей. Такая возможность сведения воедино данных исключает необходимость вносить изменения в несколько мест, что может быть причиной ошибок, и гарантирует, что вся отображаемая информация будет самой свежей.

Для того, чтобы все перерасчеты оставались актуальными, в WikiCalc была использована концепция тонкого клиента, когда хранение всей информации осуществляется на серверной стороне. Каждая электронная таблица представлена в браузере в виде элемент <table>; при редактировании ячейки на сервер отсылается вызов ajaxsetcell, а сервер затем сообщает браузеру, какие ячейки нуждаются в обновлении.

Неудивительно, что такое решение зависимо от скорости соединения между браузером и сервером. Когда задержка высокая, пользователи начинают обнаруживать между началом обновления ячейки и ее новым содержимым частое появление сообщения «Loading ...» («Загрузка ...») так, как это показано на рис.19.4. Это особенно актуально для пользователей, интерактивно редактирующих формулы при помощи настройки входных данных и ожидающих увидеть результаты в реальном времени.

Рис.19.4: Сообщение о загрузке

Кроме того, поскольку элемент &lttable> имеет те же самый размеры, что и электронная таблица, для сетки размером 100 × 100 создается 10000 DOM-объектов &lttd>, которые отъедают ресурсы памяти браузеров и еще более ограничивая размеры страниц.

Из-за этих недостатков, хотя WikiCalc был полезным в качестве автономного работающего сервера, работающего в локальной сети, он на практике был не очень удобен для того, чтобы внедрять его как часть веб-систем управления контентом.

В 2006 году Дэн Бриклин объединился с компанией Socialtext с тем, чтобы начать разработку проекта SocialCalc, варианта WikiCalc, частично переработанного с Javascript в некоторое подмножество кода на языке Perl.

Такая переработка была предназначена поддержки возможности ведения совместных работ в большем объеме и изменения внешнего вида электронный таблиц таким образом, чтобы они стали похожи на настольные приложения. Другими целями проекта были следующие:

После трех лет разработки и различных бета-версий, компания Socialtext в 2009 году выпустила SocialCalc 1,0, успешно реализовав цели разработки. Давайте теперь взглянем на архитектуру системы SocialCalc.

19.2. SocialCalc

Рис.19.5. Интерфейс SocialCalc

На рис.19.5 и рис.19.6 показаны интерфейс и классы SocialCalc, соответственно. В сравнении с WikiCalc, роль сервера была значительно сокращена. Его единственной обязанностью осталось отвечать на запросы HTTP GET и обслуживать все электронные таблицы, сериализуя их в формате save, позволяющим их сохранять; как только браузер получал данные, все расчеты, отслеживание изменений и взаимодействия пользователем теперь осуществлялись на языке Javascript.

Рис.19.6: Диаграмма классов SocialCalc

Компоненты Javascript были разработаны в стиле послойной модели MVC (Model/View/Controller — Модель/Внешний вид/Контроллер), причем задача каждого класса фокусировалась на одном аспекте:

Таблица 19.1: Содержимое и форматы ячеек

datatypet
datavalue1Q84
colorblack
bgcolorwhite
fontitalic bold 12pt Ubuntu
commentIchi-Kyu-Hachi-Yon

Мы взяли на вооружение систему объектов с минимальным количеством классов и простыми механизмами композиции/делегирования и не использовали наследование или прототипы объектов. Все символы помещаются в пространство имен SocialCalc.* с тем, чтобы избежать конфликта имен.

Каждое обновление на листе проходит через метод ScheduleSheetCommands, который принимает строку, представляющую собой команду редактирования. Некоторые обычные команды показаны в таблице 19.2. В приложении, в которое встроен SocialCalc, можно самостоятельно определять дополнительные команды при помощи добавления именованных обратных вызовов в объект SocialCalc.SheetCommandInfo.CmdExtensionCallbacks и использования команды startcmdextension для обращения к ним.

Таблица 19.2: Команды SocialCalc

    set     sheet defaultcolor blue
    set     A width 100
    set     A1 value n 42
    set     A2 text t Hello
    set     A3 formula A1*2
    set     A4 empty
    set     A5 bgcolor green
    merge   A1:B2
    unmerge A1
    erase   A2
    cut     A3
    paste   A4
    copy    A5
    sort    A1:B9 A up B down
    name    define Foo A1:A5
    name    desc   Foo Used in formulas like SUM(Foo)
    name    delete Foo
    startcmdextension UserDefined args

19.3. Цикл работы команд

Чтобы улучшить ответную реакцию, SocialCalc выполняет все перерасчеты и обновления DOM в фоновом режиме, так что пользователь может вносить новые изменения в несколько ячеек в то время, как движок выполняет более ранние изменения, записанные в очереди команд.

Рис.19.7: Цикл работы команд SocialCalc

Когда выполняется команда, объект TableEditor устанавливает свой флаг busy (занято) в состояние true (истина); затем последующие команды помещаются в очередь deferredCommands, что обеспечивает последовательный порядок выполнения команд. Как видно на диаграмме цикла событий, показанной на рис.19.7, объект Sheet продолжает посылать события StatusCallback с тем, чтобы уведомить пользователя о текущем состоянии выполнения команды, которая может находиться одном из следующих четырех состояний:

Поскольку все команды будут сохранены после того, как они будут выполнены, мы естественным образом получаем журнал аудита всех операций. Метод Sheet.CreateAuditString добавляет в журнал аудита символы новой строки с тем, чтобы каждая команда была в отдельной строке.

ExecuteSheetCommand также для каждой команды, которую он выполняет, создает команду отмены undo. Например, если в ячейке A1 находится текст «Foo» и пользователь выполняет команду set A1 text Bar, то в стек команд отмены будет помещена команда set A1 text Foo. Если пользователь нажимает кнопку Undo, то будет выполнена команда undo, которая восстановит содержимое ячейки A1 к исходному значению.

19.4. Редактор таблиц

Теперь давайте посмотрим на слой TableEditor. Он с помощью своего метода RenderContext рассчитывает экранные координаты и с помощью двух экземпляров метода TableControl управляет горизонтальной/вертикальной прокруткой.

Управление прокруткой с помощью экземпляров метода TableControl

Слой отображения, обрабатываемый классом RenderContext, также отличается от конструкции WikiCalc. Вместо отображения каждой ячейки в элемент <td>, мы теперь просто создаем элемент <table> фиксированного размер, который соответствует видимой области браузера и заранее заполняем ее элементами <td>.

Когда пользователь выполняет прокрутку с помощью наших полос прокрутки, мы динамически обновляем innerHTML предварительного нарисованными элементами <td>. Это означает, что нам не нужно в большинство случаев создавать или уничтожать какие-либо элементы <tr> и <td>, что существенно ускоряет время отклика.

Поскольку RenderContext отрисовывает только видимую область, размер объекта Sheet (Лист) может быть сколь угодно большой, что не влияет на производительность.

В TableEditor также есть объект CellHandles, в котором реализовано круговое меню fill/move/slide (заполнение/перемещение/сдвиг), прикрепленное к правому нижнему углу ячейки, редактируемой в текущий момент, которая известна как Ecell и которая показана на рис.19.9.

Рис.19.9: Ячейка, редактируемая в текущий момент и называющаяся Ecell

Управление полем ввода осуществляется двумя классами: InputBox и InputEcho. Первый из них управляет строкой редактирования, расположенной над сеткой, а второй, который показан как слой просмотра, обновляемый по мере того, как вы вводите текст, выполняет наложение содержимого ECell (рис.19.10).

Рис.19.10: Поле ввода, управляемое двумя классами

Обычно движок SocialCalc нужен только для связи с сервером в случае, когда электронная таблица открывается для редактирования и когда отправляет сохраняемые данные на сервер. Для этой цели используется метод Sheet.ParseSheetSave, который анализирует формат save, используемый для сохранения данных, и преобразует данные в объект Sheet, и метод Sheet.CreateSheetSave, сериализующий объект Sheet обратно в формат save.

В формулах можно с помощью адресов URL делать ссылки на значения в любой таблице, расположенной удаленно. Команда recalc пересчитывает электронные таблицы, в которых есть внешние ссылки, затем анализирует их с помощью метода Sheet.ParseSheetSave и сохраняет их в кэше, так что пользователь может делать ссылки на другие ячейки в той же самой таблице, расположенной удаленно, без повторного извлечения ее содержимого.

19.5. Формат сохранения save

Формат сохранения save является стандартным форматом MIME multipart/mixed, состоящим из четырех частей ext/plain; charset=UTF-8; в каждой части находится текст, разделяемый символами новой строки, с полями, которые отделяются друг от друга с помощью двоеточий. Это следующие части:

Например, на рис.19.11 показана таблица с тремя ячейками со значением 1874 в ячейке A1, представляющей собой элемент ECell, формулой 2^2*43 в ячейке A2, и формулой SUM(Foo) в ячейке A3, выделенные жирным шрифтом, и ссылкой на диапазон A1:A2 с именем Foo.

Рис.19.11: Таблица с тремя ячейками

Сериализованный формат save таблицы выглядит следующим образом:

    socialcalc:version:1.0
    MIME-Version: 1.0
    Content-Type: multipart/mixed; boundary=SocialCalcSpreadsheetControlSave
    --SocialCalcSpreadsheetControlSave
    Content-type: text/plain; charset=UTF-8

    # SocialCalc Spreadsheet Control Save
    version:1.0
    part:sheet
    part:edit
    part:audit
    --SocialCalcSpreadsheetControlSave
    Content-type: text/plain; charset=UTF-8

    version:1.5
    cell:A1:v:1874
    cell:A2:vtf:n:172:2^2*43
    cell:A3:vtf:n:2046:SUM(Foo):f:1
    sheet:c:1:r:3
    font:1:normal bold * *
    name:FOO::A1\cA2
    --SocialCalcSpreadsheetControlSave
    Content-type: text/plain; charset=UTF-8

    version:1.0
    rowpane:0:1:14
    colpane:0:1:16
    ecell:A1
    --SocialCalcSpreadsheetControlSave
    Content-type: text/plain; charset=UTF-8

    set A1 value n 1874
    set A2 formula 2^2*43
    name define Foo A1:A2
    set A3 formula SUM(Foo)
    --SocialCalcSpreadsheetControlSave--

Этот формат создавался таким образом, чтобы он был понятен человеку и чтобы его можно было относительно легко генерировать программно. Это дает возможность с помощью плагина Sheetnode из фреймворка Drupal, использующего язык PHP, выполнять преобразования между этим форматом и другими популярными форматами электронных таблиц, например, Excel (.xls) и OpenDocument (.ods).

Теперь, когда мы понимаем, как отдельные части в SocialCalc, сочетаются друг с другом, давайте рассмотрим два реальных примера расширений SocialCalc.

19.6. Расширенные возможности редактирования

Первый пример, рассматриваемый нами, является улучшенным вариантом текстовой ячейки SocialCalc, которая позволяет непосредственно в редакторе таблиц отображать тест со расширенными возможностями (рис. 19.12).

Рис.19.12: Отображение в табличном редакторе текста с расширенными возможностями редактирования

Мы добавили эту возможность в SocialCalc сразу после его релиза 1.0 для того, чтобы выполнить популярную просьбу разрешить в рамках единого синтаксиса вставлять изображения, ссылки и текстовую разметку. Поскольку Socialtext уже имел wiki-платформу с открытым исходным кодом, было естественным повторно использовать синтаксис также и для SocialCalc.

Чтобы осуществить это, нам нужно специальным образом отрисовывать textvalueformat из text-wiki и, чтобы использовать этот результат, нужно изменить формат текстовых ячеек, определяемый по умолчанию.

Вы спросите, а что такое этот формат textvalueformat? Читаем дальше.

19.6.1. Типы и форматы

В SocialCalc, каждая ячейка имеет тип данных datatype и тип значений valuetype. Ячейки данных с текстом или цифрами соответствуют типам значений text/numeric (текстовый/числовой), а ячейки с формулами типа datatype="f" могут генерировать либо числовые, либо текстовые значения.

Вспомним, что на шаге визуализации Render, объект Sheet генерирует HTML для каждой из своих ячеек. Он делает это проверяя тип значений valuetype каждой ячейки: если он начинается с t, то атрибут ячейки textvalueformat определяет, как должна выполняться генерация. Если он начинается с n, то вместо этого используется атрибут nontextvalueformat.

Однако, если атрибут ячейки textvalueformat или nontextvalueformat не определен явно, то формат, используемый по умолчанию, берется из valuetype так, как это показано на рис.19.13.

Рис.19.13: Типы значений

Поддержка формата значений text-wiki закодирована в SocialCalc.format_text_for_display следующим образом:

if (SocialCalc.Callbacks.expand_wiki && /^text-wiki/.test(valueformat)) {
    // do general wiki markup
    displayvalue = SocialCalc.Callbacks.expand_wiki(
        displayvalue, sheetobj, linkstyle, valueformat
    );
}

Вместо того, чтобы непосредственно вставлять расширение wiki-to-HTML (преобразующее wiki-текст в HTML — прим.пер.) в format_text_for_display, мы определим новый триггер в SocialCalc.Callbacks. Этот стиль рекомендуется использовать везде в коде SocialCalc; он улучшает модульность благодаря тому, что позволяет различными способами подключать расширения для wiki-текста, а также сохранять совместимость с уже встроенными возможностями, в которых этот прием не нужен.

19.6.2. Отображение wiki-текста

Затем мы будем использовать Wikiwyg [1], библиотеку Javascript, осуществляющую двунаправленное преобразование между wiki-текстом и HTML.

Мы определяем функцию expand_wiki, которая берет из ячейки текст, пропускает его через анализатор wiki-текста в Wikiwyg и выдает текст на языке HTML:

var parser = new Document.Parser.Wikitext();
var emitter = new Document.Emitter.HTML();
SocialCalc.Callbacks.expand_wiki = function(val) {
    // Convert val from Wikitext to HTML
    return parser.parse(val, emitter);
}

Последний шаг включает в себя выполнение команды text-wiki - set sheet defaulttextvalueformat сразу после инициализации таблицы:

// Мы допускаем, что в DOM уже имеется 
var spreadsheet = new SocialCalc.SpreadsheetControl(); spreadsheet.InitializeSpreadsheetControl("tableeditor", 0, 0, 0); spreadsheet.ExecuteCommand('set sheet defaulttextvalueformat text-wiki');

Если все объединит вместе, то шаг визуализации Render теперь работает так, как это показано на рис.19.14.

Рис.19.14: Шаг визуализации Render

Вот и все! Расширенный вариант SocialCalc теперь поддерживает расширенный набор синтаксиса, позволяющего использовать wiki-разметку:

*bold* _italic_ `monospace` {{unformatted}}
> indented text
* unordered list
# ordered list
"Hyperlink with label"
{image: http://www.socialtext.com/images/logo.png}

Попробуйте ввести в ячейку A1 текст *bold* _italic_ `monospace`, и вы увидите, что он отображается в уже отформатированном виде (рис.19.15).

Рис.19.15: Пример Wikywyg

19.7. Совместные работы в режиме реального времени

Следующий пример, который мы изучим, является многопользовательской общедоступной электронной таблицей, редактируемой в режиме реального времени. Это на первый взгляд может показаться сложным, но благодаря модульной конструкции SocialCalc все, что нужно для каждого он-лайн пользователя, это транслировать свои команды другим участникам работы.

Чтобы различать локально-используемые команды и команды, используемые дистанционно, мы в метод ScheduleSheetCommands добавляем параметр isRemote:

SocialCalc.ScheduleSheetCommands = function(sheet, cmdstr, saveundo, isRemote) {
   if (SocialCalc.Callbacks.broadcast && !isRemote) {
       SocialCalc.Callbacks.broadcast('execute', {
           cmdstr: cmdstr, saveundo: saveundo
       });
   }
   // … здесь собственно код ScheduleSheetCommands here…
}

Теперь все, что нам нужно сделать, это определить подходящую функцию обратного вызова SocialCalc.Callbacks.broadcast. Как только она появится, у всех пользователей, подключенных к той же самой таблице, будут выполняться одни и те же команды.

Когда эта функция была впервые реализована для OLPC (One Laptop Per Child — Проект «Каждому ребенку свой ноутбук» [2]) лабораторией Sugar Labs в Сита (Уганда) [3] в 2009 году, то функция broadcast была создана с использованием вызовов XPCOM в D-Bus/Telepathy - стандартного транспорта для сетей OLPC/Sugar (смотрите рис.19.16).

Рис.19.16: Реализация OLPC

Это работает достаточно хорошо, позволяя в одной и той же сети Sugar использовать экземпляры объектов XO для совместной работы над общей таблицей SocialCalc. Однако, есть особенности, касающиеся как браузерной платформы Mozilla/XPCOM, так и платформы обмена сообщениями D-Bus/Telepathy.

19.7.1. Кросс-браузерный транспорт

Чтобы можно было выполнять эту работу в разных браузерах и разных операционных системах, мы пользуемся фреймворком Web::Hippie [4], высокоуровневой абстракции JSON-поверх-WebSocket с удобной привязкой к Jquery и с MXHR ( запроса XML HTTP, состоящего из нескольких частей [5]) в качестве резервного механизма транспорта на случай, если WebSocket недоступен.

Для браузеров с установленным плагином Adobe Flash, но без встроенной поддержки WebSocket, мы используем Flash-эмуляцию WebSocket из проекта web_socket.js [6], которая работает даже быстрее и надежнее, чем MXHR. Поток операций показан на рис.19.17.

Рис.19.17: Кросс-браузерный поток операций

Функция клиентской стороны SocialCalc.Callbacks.broadcast определяется следующим образом:

var hpipe = new Hippie.Pipe();

SocialCalc.Callbacks.broadcast = function(type, data) {
    hpipe.send({ type: type, data: data });
};

$(hpipe).bind("message.execute", function (e, d) {
    var sheet = SocialCalc.CurrentSpreadsheetControlObject.context.sheetobj;
    sheet.ScheduleSheetCommands(
        d.data.cmdstr, d.data.saveundo, true // isRemote = true
    );
    break;
});

Хотя это работает достаточно хорошо, есть еще два оставшихся вопроса, которые нужно решить.

19.7.2. Разрешение конфликтов

Первым вопросом является состояние гонки (race-condition) во время выполнения команд: Если пользователи А и В одновременно выполняют операции, влияющие на одни и те же ячейки, принимают и выполняют команды, транслируемые другими пользователями, то в итоге они могут попасть в различающиеся состояния так, как это показано на рис.19.18.

Рис.19.18: Конфликт, связанный с состоянием гонки (Race Condition)

Мы можем решить это с помощью встроенного механизма undo/redo, имеющегося в SocialCalc, так, как показано на рис.19.19.

Рис.19.19: Разрешение конфликта, связанного с состоянием гонки (Race Condition)

Процесс, используемый для разрешения конфликта заключается в следующем. Когда клиент рассылает команду, он добавляет команду в очередь ожидания команд Pending. Когда клиент получает команду, он проверяет, не противоречит ли полученная команда той команде, которая находится в очереди ожидания команд Pending.

Если очередь отложенных команд Pending пуста, то команда просто выполняется как дистанционно исполняемое действие. Если дистанционная команда находит в очереди Pending совпадающую команду, то локальный команда удаляется из очереди.

В противном случае, клиент проверяет, есть ли в очереди команды, которые противоречат принятой команде. Если есть противоречащие команды, клиент сначала отменяет эти команды (операция Undo) и помечает их для повторного выполнения их впоследствии (операция Redo). После отмены действий конфликтующих команд (если таковые имеются), дистанционная команда выполняется обычным образом.

Когда от сервера поступает команда, отмеченная для повторного выполнения (операция Redo), клиент выполнит ее снова, а затем удалит ее из очереди.

19.7.3. Дистанционное управление курсором

Даже когда проблема с состоянием гонки решена, все еще есть шанс, что кто-нибудь случайно изменит содержимое ячейки, которое в настоящее время редактирует другой пользователь. Простым решением является рассылка каждым пользователем позиции своего курсора всем остальным пользователям с тем, чтобы все видели, в какой ячейке выполняется работа.

Чтобы реализовать эту идею, мы добавляем к событию MoveECellCallback еще один обработчик broadcast:

editor.MoveECellCallback.broadcast = function(e) {
    hpipe.send({
        type: 'ecell',
        data: e.ecell.coord
    });
};

$(hpipe).bind("message.ecell", function (e, d) {
    var cr = SocialCalc.coordToCr(d.data);
    var cell = SocialCalc.GetEditorCellElement(editor, cr.row, cr.col);
    // … оформляем ячейку в стиле, соответствующему ситуации, когда ней работает другой пользователь …
});

Чтобы пометить ячейку, которая получила фокус в таблицах, обычно используют цветные границы. Однако для ячейки может быть определено ее собственное свойство border (граница), а так как границы окрашены одинаково, в одной о той же ячейке может находиться только один курсор.

Поэтому в браузерах, в которых поддерживается CSS3, мы для представления нескольких курсоров в одной и той же ячейке используем свойство box-shadow:

/* Два курсора в в одной и той же ячейке */
box-shadow: inset 0 0 0 4px red, inset 0 0 0 2px green;

На рис.19.20 показано, как будет выглядеть экран, когда с одной и той же таблицей работаю четыре человека.

Рис.19.20: Четыре пользователя редактируют одну и ту же таблицу

19.8. Усвоенные уроки

Мы выпустили SocialCalc 1.0 19 октября 2009 года в 30-летнюю годовщину первого выпуска VisiCalc. Опыт сотрудничества с коллегами в Socialtext под руководством Дэна Бриклина был очень ценен для меня, и я хотел бы поделиться некоторыми уроками, которые я за это время усвоил.

19.8.1. Главный конструктор с ясным видением

В работе [Bro10] Фред Брукс (Fred Brooks) утверждает, что если мы ориентируемся на стройную концепцию архитектуры, обсуждения при создании сложных систем должны быть более прямыми, а не выводится из других решений. По словам Брукса, формулировку такую единой архитектурно целостную концепцию лучше всего отдать на откуп одному человеку:

Поскольку концептуальная целостность является самым важным атрибутом отличной архитектуры и т.к. это должно быть результатом размышлений одного человека или нескольких, работающих как единое целое, мудрый менеджер смело доверит всю задачу создания архитектуры одаренному главному конструктору.

В случае с SocialCalc наличие Трейси Рагглса (Tracy Ruggles) в качестве нашего главного конструктора, имеющего пользовательский опыт, было для проекта ключевым фактором, позволяющим свести все к общему видению. Поскольку движок, лежащий в основе SocialCalc, был настолько податлив, был очень велик соблазн создать массу мелких функций. Способность Трейси общаться с помощью проектных эскизов действительно помогли нам представить все функции так, как их интуитивно чувствуют пользователи.

19.8.2. Wiki для сообщества, работающего с проектом

Прежде, чем я присоединился к проекту SocialCalc, проект уже был в работе в течение более двух лет, но я смог его догнать и менее чем за неделю начал вносить в него свой вклад, причем просто благодаря тому, что все было в wiki. Весь процесс был запечатлен в wiki-страницах и таблицах SocialCalc, начиная с первых аккордов разработки архитектуры и до самой последней матрицы поддержки браузеров.

Чтение рабочего проекта быстро переводило меня на ту же самую страницу, что и других, причем без обычного размахивания руками над головой, обычно сопровождаемого вхождение в проект нового члена команды.

Это было бы невозможно в традиционных проектах с открытым кодом, когда большая часть обсуждений происходит по IRC и по спискам рассылок, а wiki-технология (если таковая имеется) используется только для документирования, а также для ссылок на ресурсы, используемые при разработке. Новичку гораздо труднее восстановить контекст проекта по неструктурированным журналам IRC и почтовым архивам.

19.8.3. Объединяем разные часовые пояса

Когда Давид Хейнемейер Ханссон (David Heinemeier Hansson), создатель Ruby on Rails, первый раз попал в команду 37signals, он как-то заметил о пользе распределенных команд: «Семь часовых поясов между Копенгагеном и Чикаго фактически означали, что мы могли делать очень много с небольшими перерывами». Это также оказалось верным, когда при разработке SocialCalc Тайбэй и Пало-Альто были разделены девятью часовыми поясами.

Мы часто завершали весь цикл от запроса до реализации в течение 24-часового рабочего дня, причем каждый вопрос решался дня одним человеком в течение 8-часового рабочего днем по его местному времени. Такой асинхронный стиль сотрудничества вынуждал нас создавать самодокументированные артефакты (эскизы проекта, код и тесты), что, в свою очередь, значительно улучшило наше доверие друг к другу.

19.8.4. Оптимизация для удовольствия

В 2006 году в своем докладе для конференции CONISLI [Tan06], я изложил в форме нескольких заметок свой собственный опыт руководства распределенной командой, реализующей язык Perl 6,. Среди них - «всегда иметь план», «снисходительность ведет к вседозволенности», «избегать тупиков», «искать идеи, а не консенсус» и «предлагать эскиз идеи вместе с кодом», которые особенно актуальны для небольших распределенных команд.

При разработке SocialCalc, мы особое внимание уделяли тому, как знания, касающиеся проекта, распределялись среди членов команды, совместно работающих над кодом, поэтому никто из нас не мог стать узким местом в проекте.

Кроме того, мы заранее решали споры пс помощью кодирования различных вариантов с тем, чтобы изучить какую-нибудь часть проекта, и, когда появлялись лучшие проектные решения, мы не боялись заменять ими полностью рабочие прототипы.

Эти нормы поведения помогли нам воспитать чувство предвидения и взаимопомощи и, несмотря на отсутствие непосредственного взаимодействия друг с другом, свести политические вопросы к минимуму и сделать так, чтобы работа над проектом SocialCalc доставляла удовольствие.

19.8.5. Управление разработкой с использованием «описаний - проверок»

До прихода в Socialtext, как можно видеть по спецификациям Perl 6 [7], в которых мы добавляли официальные наборы тестов к спецификациям языка, я выступал за подход «чередования тестов со спецификациями»,. Впрочем, еще были Кен Пьер (Ken Pier) и Мэтт Хюссер, Heusser, команда аналитиков SocialCalc, которая действительно открыла мне глаза на то, как можно перейти на следующий уровень, преобразовав тесты в исполняемые спецификации.

В главе 16 в [GR09] Мэтт разъяснил наш процесс разработки с использованием подхода «описание - проверка» следующим образом:

Основной единицей работы является «описание», которое является документом с чрезвычайно простыми спецификациями. В нем находится краткое описание предлагаемой функции и даются примеры того, что должно произойти, чтобы считать эту функцию реализованной; мы называем эти примеры «приемо-сдаточными проверками» и описываем их на простом английском языке.

В начале «описания» владелец продукта делает добросовестную первую попытку создать приемо-сдаточную проверку, которая будет расширеная разработчиками и тестерами перед тем, как кто-нибудь из разработчиков напишет какую-нибудь сточку кода.

Такие «описания - проверки» затем преобразовываются в wiki-тесты, табличный язык спецификаций, предложенный Уордом Каннингеммом во фреймворке FIT [8] и позволяющий управлять фреймворками автоматического тестирования, например, Test::WWW::Mechanize [9] и Test::WWW::Selenium [10].

Трудно переоценить пользу использования подхода «описание - проверки» в качестве обобщенного языка для формулировки и проверки требований. Он сыграл важную роль в уменьшении массы недоразумений, и позволил в наших ежемесячных выпусках практически полностью избежать возврата к старым проблемам.

19.8.6. Открытый исходный код с лицензией CPAL

Последним, но не менее важным уроком, который интересен сам по себе, является модель открытого исходного кода, выбранную нами для SocialCalc.

В Socialtext для SocialCalc была создана лицензия Common Public Attribution License [11]. Лицензия CPAL, разработанная на основе лицензии Mozilla Public License, предназначена для того, чтобы позволить автору исходного текста требовать от пользователя исходного текста отображать информацию о себе в пользовательском интерфейсе производных программ, а также имеет пункт, касающийся использования в сети, в котором требуется в производной программе в случае, если она используется в сети в качестве сервиса, указывать те же самые условия распространения, что и в исходной лицензии.

После одобрения лицензии в Open Source Initiative [12] и в Free Software Foundation [13], мы обнаружили, что такие известные сайты, как Facebook [14] и Reddit [15], решили выпустить исходный код своей платформы под лицензией CPAL, что очень обнадеживает.

Поскольку лицензия CPAL является лицензией «weak copyleft» (т. е. с весьма слабыми ограничениями — прм.пер.), разработчики могут свободно комбинировать ее с либо бесплатным, либо с проприетарным программным обеспечением, и единственное, что нужно, это создать собственную модификацию SocialCalc. Это позволило различным сообществам адаптировать SocialCalc и сделали его еще более мощным.

Движок для работы с электронными таблицами, имеющий открытый исходный код, предоставляет много интересных возможностей и если вы сможете найти способ использовать SocialCalc в вашем любимом проекте, мы, определенно, хотели бы об этом узнать.

Примечания

  1. https://github.com/audreyt/wikiwyg-js
  2. http://one.laptop.org/
  3. http://seeta.in/wiki/index.php?title=Collaboration_in_SocialCalc
  4. http://search.cpan.org/dist/Web-Hippie/
  5. http://about.digg.com/blog/duistream-and-mxhr
  6. https://github.com/gimite/web-socket-js
  7. http://perlcabal.org/syn/S02.html
  8. http://fit.c2.com/
  9. http://search.cpan.org/dist/Test-WWW-Mechanize/
  10. http://search.cpan.org/dist/Test-WWW-Selenium
  11. https://www.socialtext.net/open/?cpal
  12. http://opensource.org/
  13. http://www.fsf.org
  14. https://github.com/facebook/platform
  15. https://github.com/reddit/reddit

На главную -> MyLDP -> Тематический каталог ->

Фреймворк Telepathy

Глава 20 из книги "Архитектура приложений с открытым исходным кодом", том 1.

Оригинал: Telepathy
Автор: Danielle Madeley
Дата публикации:
Перевод: Н.Ромоданов
Дата перевода: июль 2013 г.

Creative Commons. Перевод был сделан в соответствие с лицензией Creative Commons. С русским вариантом лицензии можно ознакомиться здесь.

Telepathy является модульным фреймворком для коммуникаций в режиме реального времени, в котором можно обрабатывать голосовые, текстовые, видео сообщения, осуществлять передачу файлов и так далее. Фреймворк Telepathy уникален не столько тем, что он абстрагирует особенности различных протоколов передачи мгновенных сообщений, а тем, что он воплощает идею коммуникации как сервиса, во многом похожего на то, как печать является сервисом, доступным одновременно для многих приложений. Для достижения этого в Telepathy интенсивно используется шина передачи сообщений D-Bus и модульная архитектура.

Коммуникации, как сервис, невероятно полезны, поскольку позволяют нам с помощью них выйти за рамки отдельного приложения. Нам предлагается много интересных вариантов применения: можно видеть наличие контакта в почтовом приложении, начать с ним общение, использовать этот контакт для передачи ему файла прямо из браузера файлов или, используя контакты, выполнять совместную работу внутри приложений — возможность, известная в Telepathy как Tubes туннели.

Фреймворк Telepathy был создана Робертом Маккуином (Robert McQueen) в 2005 году, и с того времени разрабатывался и поддерживался несколькими компаниями и отдельными разработчиками, в том числе компанией Collabora, одним из сооснователей которой является Маккуин.

Шина передачи сообщений D-Bus

Шина D-Bus является шиной асинхронной передачи сообщений межпроцессного взаимодействия, что представляет собой остов большинства систем GNU/Linux, в том числе среды рабочего стола для GNOME и KDE. D-Bus является, прежде всего, архитектурой с общей шиной: приложения подключаются к шине (идентифицируемой адресом сокета) и могут либо передавать сообщения, адресованные другому приложению, подключенному к шине, либо осуществлять широковещательную передачу сигнала для всех, кто подключен к шине. Приложения, подключенные к шине, имеют шинный адрес, похожий на IP-адрес, и могут объявлять об использовании нескольких зарегистрированных за ними имен, похожих на имена DNS, например org.freedesktop.Telepathy.AccountManager. Все процессы взаимодействуют через демон D-Bus, с помощью которого происходит передача сообщений и регистрация имен.

С точки зрения пользователя, в каждой системе есть две шины. Системная шина представляет собой шиной, позволяющей пользователю взаимодействовать с компонентами, имеющимися в системе (принтерами, устройствами bluetooth, средствами управления аппаратурой и так далее), и которая совместно используется всеми пользователями системы. Сессионная шина является уникальной для каждого пользователя, то есть для каждого пользователя, который вошел в систему, имеется своя собственная сессионная шина, применяющаяся в пользовательских приложениях для связи их друг с другом. Когда через шину нужно передать большой объем трафика, в приложениях также можно создать собственную шину приложения, или можно создать шину типа «точка-точка» (peer-to-peer), управление которой не будет происходить при помощи демона dbus-daemon.

С помощью нескольких библиотек реализован протокол D-Bus, через который можно взаимодействовать с демоном D-Bus, например, помощью библиотек libdbus, GDBus, QtDBus и python-dbus. На эти библиотеки возложена обязанность отправки и получения сообщений D-Bus, преобразование типов из системы типов данных языков программирования в формат типов шины D-Bus, и публикация объектов в среде шины. Обычно в библиотеках также предоставляются удобные интерфейсы API, используемые для получения списков подключенных приложений и приложений, которые могут быть активированы, а также интерфесы для запроса имен, зарегистрированных в шине. На уровне шины D-Bus, всё это сделано с помощью вызовов методов опубликованного объекта, что выполняется демоном dbus-daemon самостоятельно.

Более подробную информацию о шине D-Bus смотрите по ссылке http://www.freedesktop.org/wiki/Software/dbus.

20.1. Компоненты фреймворка Telepathy

Telepathy является модульным фреймворком, в котором каждый модуль взаимодействует с другими через шину сообщений D-Bus. Причем, чаще всего через сессионную шину пользователя. Это взаимодействие подробно описано в спецификациях фреймворка Telepathy [2]. На рис.20.1 показаны следующие компоненты Telepathy:

В текущей реализации фреймворка Telepathy, менеджер аккаунтов и диспетчер каналов представлены в виде единого процесса, который называется центром управления (Mission Control).

Рис.20.1: Пример компонентов фреймворка Telepathy

Такая модульная архитектура основывается на философии Дага Макилроя (Doug McIlroy): «Пишите программы, которые делают что-то одно, и делают это хорошо», и имеет несколько важных преимуществ:

Менеджер соединений управляет некоторым количеством соединений, каждое соединение представляет собой логическое подключение к некоторому коммуникационному сервису. В каждом аккаунте есть одно соединение. В соединении может быть много каналов. Каналы являются механизмом, через который происходит коммуникация. С помощью каналов реализовывают обмен мгновенными сообщениями IM, голосовые или видео вызовы, пересылку файлов, или какой-либо другие операции, требующие сохранение состояния (stateful operation). Соединения и каналы более подробно рассматриваются в разделе 20.3.

20.2. Как в Telepathy используется шина D-Bus

Взаимодействие компонентов Telepathy осуществляется через шину обмена сообщениями D-Bus, которая обычно является сессионной шиной пользователя. В D-Bus предоставляются возможности, обычные для систем межпроцессных взаимодействий: каждый сервис публикует объекты, имеющие строго определенные пути в пространстве имен, ведущие к этим объектам, например /org/freedesktop/Telepathy/AccountManager [3]. В каждом объекте реализовано несколько интерфейсов. Интерфейсы снова строго определены в пространстве имен, и имеют вид org.freedesktop.DBus.Properties и ofdT.Connection. В каждом интерфейсе предоставляются методы, сигналы и свойства, которые вы можете вызывать, слушать или опрашивать.

Рис.20.2: Концептуальное представление объектов, публикуемых сервисом D-Bus

Публикация объектов D-Bus

Публикация объектов D-Bus полностью выполняется библиотекой D-Bus, которая используется в данный момент. По сути, это отображение пути к объекту D-Bus в объект, используемый в программе и реализующий данные интерфейсы. Пути к объектам, публикуемые сервисом, предоставляются для доступа с помощью дополнительного интерфейса org.freedesktop.DBus.Introspectable.

Когда сервис получает входящий вызов метода, в котором указан путь назначения (например, /ofdT/AccountManager), на библиотеку D-Bus возлагается обязанность найти в программе объект, представляемый данным объектом D-Bus, а затем выполнить вызов соответствующего метода для этого объекта.

Интерфейсы, методы, сигналы и свойства, предоставляемые Telepathy, подробно описываются с помощью языка D-Bus IDL (Interface Definition Language, язык описания интерфейсов), в основе которого лежит язык XML, расширенный таким образом, чтобы можно было включать больше информации. Спецификации в таком виде можно анализировать автоматически и создавать документацию и привязки к различным языкам программирования.

Сервисы Telepathy публикуют различные объекты на шине. Центр управления публикует объекты для менеджера соединений и диспетчера каналов так, чтобы был доступ к их сервисам. Клиентские программы публикуют объект Client так, чтобы он был доступен для диспетчера каналов. Наконец, менеджеры соединений публикуют следующие объекты: объект сервиса, который может использоваться менеджером аккаунтов для запроса новых соединений, по одному объекту для каждого открытого соединения и по одному объекту для каждого открытого канала.

Хотя объекты D-Bus не имеют типов, а только интерфейсы, в Telepathy типы эмулируются несколькими способами. Путь к объекту, сообщающий нам, где находится объект, является соединением, каналом, клиентской программой, и т.д., хотя обычно вы уже знаете об этом, когда запрашиваете прокси доступ для этого объекта. Каждый объект реализует базовый интерфейс для его собственного типа, например ofdT.Connection или ofdT.Channel. Для каналов, это очень похоже на абстрактный базовый класс. Объекты каналов также имеют конкретный класс, определяемый типом данного канала. Снова это представлено с помощью интерфейса шины D-Bus. О типе канала можно узнать, прочитав свойство ChannelType в интерфейсе Channel.

Наконец, в каждом объекте реализуется ряд необязательных интерфейсов (как ни странно, тоже представляемые как интерфейсы шины D-Bus), которые зависят от возможностей протокола и менеджера соединений. Список имеющихся интерфейсов для данного объекта представлены в свойстве Interfaces базового класса объекта.

Для объектов соединений Connection типа \code{ofdT.Connection}, необязательные интерфейсы имеют имена вида - ofdT.Connection.Interface.Avatars (если в протоколе есть понятие аватаров), odfT.Connection.Interface.ContactList (если в протоколе предоставлен реестр контактов – это делается не везде) и odfT.Connection.Interface.Location (если в протоколе предоставлена геолокационная информация). Для объектов каналов Channel типа ofdT.Channel, конкретные классы имеют имена интерфейсов вида ofdT.Channel.Type.Text, odfT.Channel.Type.Call и odfT.Channel.Type.FileTransfer. Необязательный интерфейс точно также, как и объекты Connection, имеют имена вида odfT.Channel.Interface.Messages (если канал может передавать и получать текстовые сообщения) и odfT.Channel.Interface.Group (если данный канал подключен к группе, содержащей много контактов, например, к многопользовательскому чату). Так, например, в текстовом канале реализуются, как минимум, интерфейсы ofdT.Channel, ofdT.Channel.Type.Text и Channel.Interface.Messages. Если это многопользовательский чат, в нем также будет реализован интерфейс odfT.Channel.Interface.Group.

Почему свойство Interfaces, а не анализ в самой шине D-Bus?

Вы можете поинтересоваться, почему в каждом базовом классе реализовано свойство Interfaces, вместо того, чтобы воспользоваться средствами анализа, имеющимися в самой шине D-Bus, которые бы нам сообщали, какие имеются интерфейсы. Дело в том, что в различных объектах каналов и соединений могут, в зависимости от возможностей канала или соединения, предлагаться различные интерфейсы, которые отличаются друг от друга, но в большинстве реализаций средств анализа D-Bus предполагается, что все объекты одного и того же класса объектов будут иметь одинаковые интерфейсы. Например, в telepathy-glib интерфейсы D-Bus, перечисляемые с помощью средств анализа D-Bus, берутся их реализаций классов интерфейсов объекта, которые определяются статически на этапе компиляции. Мы справляемся со всем этим благодаря наличию средств анализа в шине D-Bus, предоставляющих данные обо всех интерфейсах, которые могли бы быть в объекте, и благодаря использованию свойства Interfaces, указывающего, какие интерфейсы действуют на самом деле.

Хотя в самой шине D-Bus нет проверки того, что в объектах соединений присутствуют интерфейсы, относящиеся только к подключениям, и т. д. (поскольку в шине D-Bus не используется концепция типов, а лишь концепция интерфейсов, именуемых произвольным образом), мы для того, чтобы реализовывать такую проверку внутри языковых привязок фреймворка Telepathy, можем пользоваться информацией, имеющейся в спецификациях Telepathy.

Почему и как был расширен язык спецификаций IDL

В существующем языке спецификаций IDL шины D-Bus определяются имена, аргументы, ограничения доступа и сигнатуры методов, свойств и сигналов для типов D-Bus. Но в нем не поддерживается документирование, механизмы привязок и именованные типы.

Чтобы обойти эти ограничения, в XML было добавлено новое пространство имен XML, предоставляющее требуемую информацию. Это пространство имен для того, чтобы его можно было использовать другими интерфейсами API шины D-Bus, было разработано в наиболее обобщенном виде. Были добавлены новые элементы, среди которых есть возможность прямо внутрь интерфеса API добавлять документацию, описание обоснования тех или иных решений, вводную часть, отмечать устаревшие версии и возможные исключения, возникающие в методах.

Сигнатуры типов D-Bus являются низкоуровневым описанием, определяющим какие данные должны быть сериализированы использования в шине. Сигнатура типов D-Bus могут выглядеть, например, как (ii) (что представляет собой структуру, содержащую два элемента int32), или может быть более сложной. Например, a{sa(usuu)} - это ассоциативный массив, который ассоциирует строку с массивом структур, содержащих элементы uint32, string, uint32, uint32 (смотрите рис.20.3). Эти типы, хотя и описывают формат данных, не придают никакого семантического смысла информации, хранящейся в объектах этих типов.

Чтобы предоставить программистам семантическую прозрачность и усилить типизацию привязок к языкам программирования, были добавлены новые элементы, позволяющие именовать простые типы, структуры, ассоциативные массивы, перечислимые типы (enums) а также флаги, предоставлявшие их сигнатуры, а также документацию. Также были добавлены элементы, эмулирующие наследование объектов шины D-Bus.

Рис.20.3: Тип (ii) и тип a{sa(usuu)} шины D-Bus

20.2.1. Хэндлы ( Handles)

Хэндлы используются в Telepathy для представления идентификаторов (например, контактов или названия чат-комнат). Они являются беззнаковыми целочисленными значениями, назначаемые менеджером соединений так, что кортеж (соединение, тип хэндла, хэндл) уникальным образом определял конкретный контакт или чат-комнату.

Из-за того, что различные протоколы по-разному обращаются с идентификаторами (например, это касается чувствительность к регистру, ресурсам), хэндлы предоставляют клиентам способ определить, являются ли два идентификатора одним и тем же одинаковыми. Можно запросить хэндлы различных идентификаторов, и если номера хэндлов совпадают, то идентификаторы указывают на один и тот же контакт или чат-комнаты.

Правила нормализации идентификаторов для различных протоколов различны, поэтому было бы ошибкой, если бы клиентские программы при их сравнении сравнивали бы их как строки. Например, escher@tuxedo.cat/bed и escher@tuxedo.cat/litterbox являются двумя экземплярами одного и того же контакта (escher@tuxedo.cat) в протоколе XMPP и, следовательно, они имеют одинаковый хэндл. Клиентские программы могут запрашивать каналы либо по идентификатору, либо по хэндлу, но при сравнении они всегда должны использовать хэндлы.

20.2.2. Обнаружение сервисов Telepathy

Некоторые сервисы, такие как менеджер аккаунтов и диспетчер каналов, которые существуют всегда, имеют общеизвестные имена, которые определены в спецификации Telepathy. Однако, имена менеджеров соединений и клиентских программ строго не определяются и их нужно обнаруживать динамически.

В Telepathy нет сервиса, который бы отвечал за регистрацию запущенных менеджеров соединений и клиентских программ. Вместо этого, те, кому это интересно, слушают шину D-Bus с тем, чтобы узнавать о появлении нового сервиса. Демон шины D-Bus посылает сигнал каждый раз, когда на шине появляется новый именованный сервис D-Bus. Имена клиентов и менеджеров соединений начинаются с известных префиксов, определяемых спецификацией, и в именах новых сервисов будут присутствовать эти префиксы.

Преимущество такого подхода в том, что для него абсолютно не нужно запоминать состояния (stateless). Когда запускается компонент Telepathy, он может спросить у демона шины (у которого есть канонический список, составленный на основе открытых соединений) о том, какие сервисы в настоящий момент работают. Например, если происходит сбой в работе менеджера аккаунтов, то он может посмотреть список запущенных соединений и заново создать ассоциации между соединениями и объектами аккаунтов.

Соединения тоже являются сервисами

Как и сами менеджеры соединений, соединения точно также публикуются как сервисы шины D-Bus. Гипотетически менеджеру соединений разрешается выделять каждое соединение в виде отдельного процесса, но до настоящего времени это в менеджере соединений пока реализовано. На практике это позволяет находить все запущенные соединения, запросив у демона шины D-Bus все соединения, которые начинаются с префикса ofdT.Connection.

Диспетчер каналов также использует этот же метод для поиска клиентских программ Telepathy. Они начинаются с имени ofdT.Client, например, ofdT.Client.Logger.

20.2.3. Снижение трафика шины D-Bus

Первоначальные версии спецификации Telepathy были причиной излишнее большого трафика в шине D-Bus из-за того, что огромное количество клиентских программ, подключенных к шине, вызывали методы, запрашивающие необходимую им информацию. В более поздних версиях Telepathy эта проблема была решена с помощью ряда оптимизаций.

Вызовы отдельных методов были заменены свойствами шины D-Bus. В первоначальной спецификации для каждого свойства объекта использовался отдельный вызов метода: GetInterfaces, \code{GetChannelType}, и т.д. Запрос всех свойств объекта требовал вызовов нескольких методов, у каждого из которых имелись свои накладные расходы. Если пользоваться свойствами шины D-Bus, то все это можно получить за один раз с помощью стандартного метода GetAll.

Более того, целый ряд свойств канала остается неизменным в течение всего времени существования канала. К ним относятся такие свойства, как тип канала, те интерфейсы, через которые подключаются к каналу, а также инициатор открытия канала. Например, для канала передачи файлов среди них также будут указаны размер файла и тип содержимого файла.

Был добавлен новый сигнал, с помощью которого объявляется о создании каналов (как входящих, так и в ответ на исходящие запросы) и в котором находится хэш-таблица неизменяемых свойств. Она может быть передана напрямую в прокси конструктор канала (смотрите раздел 20.4), что позволяет заинтересованным в этой информации клиентским программам не запрашивать ее по-отдельности.

Аватары пользователей передаются по шине в виде массивов байтов. Хотя в Telepathy уже используются токены для ссылок на аватары, что позволяет клиентским программам знать, что им нужен новый аватар, и не загружать ненужные аватары, каждая клиентская программа должна была отдельно запрашивать аватар через метод RequestAvatar, который в своем ответе возвращает аватар. Таким образом, когда менеджер соединений посылал сигнал о том, что у контакта обновился аватар, нужно было для получения аватара делать несколько отдельных запросов и из-за этого аватар передавался по шине сообщений несколько раз.

Эта проблема была решена с помощью добавления нового метода, который не возвращает аватар (он ничего не возвращает). А вместо этого он добавляет аватар в очередь запросов. Получение аватара по сети осуществляется по сигналу AvatarRetrieved, который могут слушать все заинтересованные клиентские программы. Это значит, что данные аватара требуется передавать по шине всего один раз, а затем они становятся доступными для всех клиентских программ, которые в нем заинтересованы. Поскольку запрос клиентской программы находился в очереди, все последующие запросы клиентских программ могли игнорироваться до тех пор, пока не будет отправлен сигнал о готовности аватара AvatarRetrieved.

Всякий раз, когда нужно загрузить большое количество контактов (например, при загрузке реестра контактов), нужно запрашивать большое количество информации: алиасы контактов, аватары, функциональные возможности, их принадлежность к группам, а также, возможно, их местоположение, адреса, номера телефонов. Ранее, в Telepathy для этого требовалось бы по одному вызову отдельного метода на каждую группу информации (большинство вызовов API, например, GetAliases уже принимало списки контактов), что приводило более, чем к полудесятку или даже к большему числу вызовом методов.

Чтобы решить эту проблему, был добавлен интерфейс Contacts. Он позволяет с помощью вызова единственного метода возвращать информацию из нескольких интерфейсов. Чтобы можно было добавлять атрибуты контактов (Contact Attributes), была расширена спецификация Telepathy: для получения информации о контакте был использован метод GetContactAttributes, который возвращал свойства, указываемые в виде полных имен из пространства имен, и который заменил вызовы других методов. Клиентская программа вызывает метод GetContactAttributes со списком контактов и интерфейсов, которые ее интересуют, а обратно она получает карту контактов, в которой для всех атрибутов контактов указываются их значения.

Небольшое количество кода сделает это более понятным. Запрос выглядит следующим образом:

connection[CONNECTION_INTERFACE_CONTACTS].GetContactAttributes(
  [ 1, 2, 3 ], # contact handles
  [ "ofdT.Connection.Interface.Aliasing",
    "ofdT.Connection.Interface.Avatars",
    "ofdT.Connection.Interface.ContactGroups",
    "ofdT.Connection.Interface.Location"
  ],
  False # don't hold a reference to these contacts
)

а ответ может выглядеть следующим образом:

{ 1: { 'ofdT.Connection.Interface.Aliasing/alias': 'Harvey Cat',
       'ofdT.Connection.Interface.Avatars/token': hex string,
       'ofdT.Connection.Interface.Location/location': location,
       'ofdT.Connection.Interface.ContactGroups/groups': [ 'Squid House' ],
       'ofdT.Connection/contact-id': 'harvey@nom.cat'
     },
  2: { 'ofdT.Connection.Interface.Aliasing/alias': 'Escher Cat',
       'ofdT.Connection.Interface.Avatars/token': hex string,
       'ofdT.Connection.Interface.Location/location': location,
       'ofdT.Connection.Interface.ContactGroups/groups': [],
       'ofdT.Connection/contact-id': 'escher@tuxedo.cat'
     },
  3: { 'ofdT.Connection.Interface.Aliasing/alias': 'Cami Cat',
        ⋮    ⋮    ⋮
     }
}

20.3. Соединения, каналы и клиентские приложения

20.3.1. Соединения

Соединение создается менеджером соединений с тем, чтобы подключиться к отдельному протоколу/аккаунту. Например, подключение к аккаунтам XMPP escher@tuxedo.cat и cami@egg.cat должно в результате привести к созданию двух соединений, каждое из которых представлено объектом на шине D-Bus. Соединения обычно настраиваются менеджером аккаунтов для аккаунтов, имеющихся в данный момент.

В соединениях предоставляются некоторые обязательные функциональные возможности, необходимые для того, чтобы управлять и следить за состоянием сетевых соединений, а также для того, чтобы выполнять запрос каналов. В них также может быть предоставлен, в зависимости от возможностей протокола, ряд дополнительных возможностей. Они предоставляются как дополнительные интерфейсы шины D-Bus (так, как это рассказывалось в предыдущем разделе), которые перечисляются в свойстве Interfaces соединения.

Обычно соединениями управляет менеджер аккаунтов, созданный с использованием свойств соответствующих аккаунтов. Менеджер аккаунтов также будет для каждого аккаунта синхронизировать присутствие пользователя в соответствующем соединении и может для указанного аккаунта запросить путь к соединению.

20.3.2. Каналы

Каналы являются механизмом, через который осуществляются коммуникации. Обычно каналом может быть обмен мгновенными сообщениями IM, голосовой или видео звонок или передача файла, но каналы также можно использовать для предоставления состояний самого сервера в случае, когда требуется сохранять состояние (например, поиск чат-комнат или контактов). Каждый канал представлен объектом шины D-Bus.

Каналы обычно образуются между двумя или большим количеством пользователей, одним из которых являетесь вы сами. Обычно у канала есть целевой идентификатор, который является либо еще одним контактом в случае соединения типа «один-один», либо идентификатором чат-комнаты в случае соединения сразу со многими пользователями (например, с чат-комнатой). В многопользовательских каналах предоставляется интерфейс Group, который позволяет вам отслеживать, какие контакты в данный момент подключены к каналу.

Каналы принадлежат соединению, и их запрос происходит из менеджера соединений обычно через диспетчер каналов; либо они создаются самим соединением в ответ на событие сети (например, входящий чат), а затем передаются диспетчеру каналов для диспетчеризации.

Тип канала определяется свойством канала ChannelType. Основные возможности, методы, свойства и сигналы, которые необходимы для данного типа каналов (например, для отправки и приема текстовых сообщений), определены в соответствующем интерфейсе Channel.Type шины D-Bus, например, в Channel.Type.Text. В некоторых типах каналов могут быть реализованы дополнительные возможности (например, шифрование), которые представлены в качестве дополнительных интерфейсов, перечисляемых в свойстве Interfaces. Например, текстовый канал, который подключается к пользователю многопользовательской чат-комнаты, может иметь интерфейсы, перечисленные в таблице 20.1.

Таблица 20.1: Пример канала текстовых сообщений

СвойствоНазначение
odfT.Channel

Общие возможности для всех каналов

odfT.Channel.Type.Text

Тип канала, включающий в себя возможности, общие для всех каналов обмена текстовыми сообщениями

odfT.Channel.Interface.Messages

Средства обмена сообщениями, имеющими расширенные возможности форматирования

odfT.Channel.Interface.Group

Компоненты перечисления (list), отслеживания (track), приглашения (invite) и одобрения (approve) для этого канала

odfT.Channel.Interface.Room

Чтение и назначение таких свойств, как тема чат-комнаты

Каналы списков контактов: ошибка

В первых версиях спецификации Telepathy списки контактов рассматривались как тип канала. Было несколько списков контактов, определяемых сервером (пользователи-подписчики, пользователи, публикующие сообщения, заблокированные пользователи), которые можно было запросить у каждого соединения. Затем точно также, как и в случае с многопользовательским чатом, можно было содержимое списка получить с помощью интерфейса Group.

Первоначально это позволяло создавать канал только после того, как список контактов был полностью получен, на что в некоторых протоколах затрачивается много времени. Клиент мог запрашивать канал когда угодно, и получать его, как только он был готов, но для пользователей с большим количеством контактов это означало, что запрос мог вызвать таймаут.

Группы контактов (например, Friends - Друзья) также предоставлялись в виде каналов, по одному каналу на каждую группу. Это при разработке клиентских приложений, которые работали с ними, приводило к исключительно трудным ситуациям. Для таких операций, как получение списка групп, к которым принадлежал контакт, требовалось в клиентском приложении писать код значительного размера. Более того, поскольку информация была доступна только через каналы, такие свойства, как группы контактов или состояние подписки, нельзя было публиковать через интерфейс Contacts.

Теперь оба этих вида каналов были заменены интерфейсами, предоставляемыми самим соединением, которые предоставляют информацию о реестре контактов в виде, более удобном для авторов клиентских приложений, и которое, включает в себя информацию о состоянии подписки контакта (тип enum), списке групп, в которые входит контакт, и списке контактов в группе. Когда список контактов будет подготовлен, об этом будет сообщено с помощью сигнала.

20.3.3. Запрос каналов, свойств каналов и диспетчеризация

Запрос каналов осуществляется с указанием карты свойств, которые, как вы желаете, должны предоставляться каналом. Обычно при запросе канала указывается тип канала, тип целевого хэндла (контакт или чат-комната) и сама цель. Однако, при запросе канала могут также указываться такие свойства, как имя или размер файла в случае передачи файлов, будет ли при вызовах сразу включаться аудио и видео, какие каналы из существующих следует объединить в вызов типа «конференция» или на каком сервере контактов осуществлять поиск контакта.

Свойства в запрашиваемом канале являются свойствами, определяемыми в спецификациях фреймворка Telepathy, например, свойство ChannelType (смотрите таблицу 20.2). Указывается полностью квалифицированное пространство имен интерфейса, откуда берутся свойства, которые можно указывать в запросах каналов и которые, согласно спецификациям фреймворка Telepathy, помечены как requestable (запрашиваемые).

Таблица 20.2: Примеры запросов каналов

СвойствоЗначение
ofdT.Channel.ChannelTypeofdT.Channel.Type.Text
ofdT.Channel.TargetHandleTypeHandle_Type_Contact (1)
ofdT.Channel.TargetIDescher@tuxedo.cat

В более сложном примере, приведенном в таблице 20.3, делается запрос канала передачи файлов. Обратите внимание на то, как запрашиваемые свойства квалифицируются с помощью интерфейсов, из которых они берутся. Для упрощения показаны не все необходимые свойства.

Таблица 20.3: Запрос канала передачи файлов

СвойствоЗначение
ofdT.Channel.ChannelTypeofdT.Channel.Type.FileTransfer
ofdT.Channel.TargetHandleTypeHandle_Type_Contact (1)
ofdT.Channel.TargetIDescher@tuxedo.cat
ofdT.Channel.Type.FileTransfer.Filenamemeow.jpg
ofdT.Channel.Type.FileTransfer.ContentTypeimage/jpeg

Каналы могут быть в двух состояниях created (созданы) или ensured (гарантируется, что они будут созданы). Гарантирование создания канала означает, что он будет создан только том в случае, если он еще не существует. Запрос на создание канала приведет либо к тому, что будет создан абсолютно новый канал, либо запрос завершится ошибкой в случае, если несколько копий такого канала не могут существовать одновременно. Обычно вам потребуется лишь гарантировать, чтобы были созданы каналы передачи текстовых сообщений и каналы звонков (т.е. вам достаточно, чтобы для разговоров с одним человеком был открыт только один канал, и, более того, во многих протоколах не поддерживает одновременно несколько разговоров с одним и тем же самым контактом), а создавать каналы нужно будет только передачи файлов и в случае каналов, в котором сохраняется их собственное состояние (stateful channels).

Оповещение о только что созданных каналах (по запросу или как-нибудь иначе) осуществляется с помощью сигнала, поступаемого из соединения. В этом сигнале содержится карта неизменяемых свойств канала (immutable properties). Это такие свойства, для которых гарантированно, что они не будут изменяться в течение времени существования канала. К свойствам, которые рассматриваются как неизменяемые и которые согласно спецификации Telepathy помечаются как immutable, обычно относятся следующие: тип канала, тип целевого хэндла, цель канала, инициатор создания канала и список реализуемых интерфейсов. Ясно, что свойства, описывающие текущее состояние канала не являются неизменяемыми.

Запрос каналов — старый подход

Изначально, каналы можно было запрашивать по их типу, типу хэндла и цели. Этот подход был недостаточно гибким, т. к. не для всех каналов указывается цель их создания (например, каналы поиска контактов), а для некоторых запросов каналов требуется в первоначальный запрос включать дополнительную информацию (например, при передаче файлов, получении голосовой почты и отправке СМС).

Также было обнаружено, что при запросе канала хорошо было бы иметь два различных варианта поведения (либо всегда создавать гарантированно уникальный канал, либо просто обеспечивать, чтобы канал существовал), причем еще до того момента, когда соединении не будет принято решение о том, какому поведению следует отдать предпочтение. Поэтому старый метод был заменен новыми, более гибкими и явными.

Возврат неизменяемых свойств канала, когда вы его создаете или обеспечиваете, чтобы он был создан, позволяет гораздо быстрее создавать прокси-объект для канала. Теперь эту информацию запрашивать не нужно. В ассоциативном массиве в таблице 20.4 показаны неизменяемые свойства, которые можно было бы добавить, когда делает запрос канала текстовых сообщений (например, в запросе канала, показанном в таблице 20.3). С целью упрощения некоторые свойства (включая TargetHandle и InitiatorHandle не показываются.

Таблица 20.4: Пример неизменяемых свойств, возвращаемых новым каналом

СвойствоЗначение
ofdT.Channel.ChannelTypeChannel.Type.Text
ofdT.Channel.Interfaces{[} Channel.Interface.Messages, Channel.Interface.Destroyable, Channel.Interface.ChatState {]}
ofdT.Channel.TargetHandleTypeHandle_Type_Contact (1)
ofdT.Channel.TargetIDescher@tuxedo.cat
ofdT.Channel.InitiatorIDdanielle.madeley@collabora.co.uk
ofdT.Channel.RequestedTrue
ofdT.Channel.Interface.Messages.SupportedContentTypes{[} text/html, text/plain {]}

Запрашивающая программа обычна отправляет запрос на канал диспетчеру каналов, передавая аккаунт, для которого производится запрос, запрос на канал и, возможно, имя требуемой программы-обработчика (это целесообразно, когда желательно, чтобы программа сама обрабатывала канал). Передача имени аккаунта вместо передачи соединения, означает, что диспетчер каналов может, если это потребуется, попросить менеджер аккаунтов перевести статус аккаунта в онлайн.

Когда запрос канала будет выполнен, диспетчер каналов либо передаст канал в указанный обработчик (handler), либо найдет подходящий обработчик (смотрите ниже обсуждение, относящееся к обработчикам и другим клиентским программам). Когда имя нужного обработчика является необязательным параметром, то у программы появляется возможность не интересоваться тем, ем, что в коммуникационных каналах при запросах каналов происходит за границами первоначального запроса, и отдавать все это на откуп тому обработчику, который для этого подходит наилучшим образом (например, запуская чат текстовых сообщений из вашего почтового клиента).

Рис.20.4: Запрос канала и диспетчеризация

Запрашивающая программа передает запрос канала в диспетчер каналов, который, в свою очередь, перенаправляет этот запрос в соответствующее соединение. Соединение выдает сигнал нового канала NewChannels, получаемый диспетчером каналов, который затем ищет подходящую клиентскую программу для обработки канала. Диспетчеризация входящих каналы, запросы на которые не делались, осуществляется аналогичным образом, когда сигнал, поступающий от соединения, получает диспетчер каналов, но, конечно, без первоначального запроса из программы.

20.3.4. Клиентские программы

Клиентские программы обрабатывают или наблюдают за входящими и исходящими коммуникационными каналами. Клиентской программой может быть все, что угодно, что зарегистрировано диспетчером каналов. Существует три вида клиентских приложений (хотя одно и то же клиентское приложение может, по желанию разработчика, относиться к двум или ко всем трем видам клиентских приложений):

Клиентским программам предоставляются сервисы D-Bus с одним, двумя или тремя следующими интерфейсами: Client.Observer, Client.Approver и Client.Handler. В каждом интерфейсе есть метод, который может вызваться диспетчером каналов для того, чтобы проинформаровать клиентскую программу о канале, за которым осуществляется наблюдение (т. е. будут выполнены операции, реализуемые с помощью клиентской программы типа Observer), относительно которого принимается решение о подтверждении или отклонения с ним работы(т. е. будут выполнены операции, реализуемые с помощью клиентской программы типаApprover) и который будет обрабатываться (т. е. будут выполнены операции, реализуемые с помощью клиентской программы типа Handler).

Диспетчер каналов, в свою очередь, осуществляет распределение канала по всем группам клиентских приложений. Сначала канал распределяется по всем соответствующим наблюдателям (клиентским программам типа Observer). После того, как поступят все ответы, канал будет распределен по всем соответствующим согласователям (клиентским программам типа Approver). После того, как первый согласователь одобрит или отклонит использование канала, об этом будут проинформированы все другие согласователи и канал, наконец, будет отправлен обработчику (клиентской программе типа Handler). Диспетчеризация канала происходит поэтапно, поскольку прежде, обработчик начнет изменять состояние канала, программам-наблюдателям может потребоваться время с тем, чтобы выполнить некоторые настройки.

У клиентских программ есть свойство — свойство фильтра канала, в котором перечисляются фильтры, считываемые диспетчером каналов с тем, чтобы знать, какие каналы интересуют данное клиентское приложение. В фильтре должны быть указаны, как минимум, тип канала и тип целевого хэндла (например, контакт или чат-комната), которые интересуют клиентскую программу, но в нем также можно указывать и другие свойства. Сравнение с неизменяемыми свойствами канала выполняется с помощью простой операции сравнения. В таблице 20.5 показан фильтр, который без всяких изменений должен присутствовать во всех каналах текстовых сообщений.

Рис.20.5: Пример фильтра канала

СвойствоЗначение
ofdT.Channel.ChannelTypeChannel.Type.Text
ofdT.Channel.TargetHandleTypeHandle_Type_Contact (1)

Поиск клиентских приложений осуществляется через шину D-Bus, поскольку они публикуют сервисы, имя которых начинается с хорошо известного префикса ofdT.Client (например, ofdT.Client.Empathy.Chat). Они также могут установить файл, из которого диспетчер каналов прочитает фильтры. Это позволит диспетчеру каналов запустить клиентское приложение в случае, если оно еще не запущено. То, что клиентские приложения можно искать таким образом, позволяет делать пользовательский интерфейс настраиваемым, который можно изменять в любой момент, не заменяя при этом каких-либо других частей фреймворка Telepathy.

Всё или ничего

Есть возможность задать фильтр, указывающий, что вам интересны все каналы, но на практике это используется только в качестве примера наблюдения за каналами. В реально существующих клиентских приложениях есть участки кода, которые специально предназначены для определенных типов каналов.

Пустой фильтр означает, что обработчика не интересуют никакие каналы. Не смотря на это, есть возможность передать канал такому обработчику, если вы сделаете это, указав имя обработчика. Такие фильтры используются во временно создаваемых обработчиках, которые создаются по запросу.

20.4. Роль привязок к языкам программирования

Поскольку фреймворк Telepathy является интерфейсом API для D-Bus, им можно управлять с помощью любого языка программирования, в котором поддерживается работа с шиной D-Bus. Для Telepathy не требуются привязки к языкам программирования, но привязками можно пользоваться с целью удобства в работе.

Языковые привязки можно разделить на две группы: низкоуровневые привязки, в которых есть код, сгенерированный с использованием спецификаций, констант, названий методов, и т.д., и высокоуровневые привязки, написанные программистами для того, чтобы облегчить другим программистам возможность что-либо делать с использованием фреймворка Telepathy. Примерами высокоуровневых привязок являются привязки к GLib и к Qt4. Примерами низкоуровневых привязок являются привязки к языку Python и привязки к языку C для библиотеки libtelepathy, входящей в состав фреймворка; впрочем, привязки к GLib и к Qt4 также относятся к низкоуровневым привязкам.

20.4.1. Асинхронное программирование

В языковых привязках все обращения к методам, с помощью которые осуществляются запросы к шине D-Bus, являются асинхронными: выполняется запрос, а ответ осуществляется через функцию обратного вызова (через callback). Это необходимо, поскольку сама шина D-Bus является асинхронной.

Как и в большинстве случаев программирования сетевых и пользовательских интерфейсов, программирование в шине D-Bus требует использовать цикл обработки событий с тем, чтобы выполнять диспетчеризацию обратных вызовов для входящих сигналов и результатов вызовов методов. Шина D-Bus хорошо интегрируется с главным циклом GLib, используемых в инструментальных наборах GTK+ и Qt.

В языковых привязках шины D-Bus (например, для dbus-glib) предоставляет псевдо-синхронный интерфейс API, в котором главный цикл блокируется до тех пор, пока метод не вернет результат. Когда-то это можно было делать с помощью привязок к API telepathy-glib. К сожалению, использование псевдо-синхронного интерфейса API привело к возникновению проблем и, в конце концов, эта возможность была убрана из telepathy-glib.

Почему не работают псевдо-синхронные обращения к шине D-Bus

Псевдо-синхронный интерфейс, предоставляемый dbus-glib и другими привязками D-Bus, реализован с использованием техники вида «запрос и блокирование». Когда устанавлевается блокировка, только для сокета D-Bus выполняется опрос новых событиях ввода-вывода, а все сообщения шины D-Bus, которые не являются ответом на запрос, помещаются в очередь для последующей обработки.

Это ведет к нескольким серьезным и неизбежным проблемам:

Вызовы методов в первых привязках Telepathy, сгенерированных для языка C, просто пользовались функцией обратного вызова для typedef. Ваша функция обратного вызова просто должна реализовывать сигнатуру того же самого типа.

typedef void (*tp_conn_get_self_handle_reply) (
    DBusGProxy *proxy,
    guint handle,
    GError *error,
    gpointer userdata
);

Эта идея проста, и работает для языка C, поэтому она продолжала использоваться в следующем поколении привязок.

В последние годы был разработан способ, позволяющий использовать такие скриптовые языки, как, например, Javascript и Python, и язык, похожий на язык C# и называющийся Vala, так, чтобы интерфейсами API, базирующимися на Glib/GObject, стало можно пользоваться через инструментальное средство, называемое GObject-Introspection. К сожалению, очень сложно сделать так, чтобы привязать использование обратных вызовов этих типов к другим языкам программирования. Поэтому новые привязки создавались такимим, чтобы они могли использовать возможности асинхронных обратных вызовов, предоставляемыми этими языками и библиотекой GLib.

20.4.2. Готовность объектов

В простом интерфейсе API для шины D-Bus, таком как низкоуровневые привязки фреймворка Telepathy, вы можете начать создавать вызовы методов или принимать сигналы для объекта просто с помощью создания для него прокси-объекта. Это столь же просто, как передать путь к объекту и имя интерфейса и произвести запуск.

Однако в высокоуровневом API фреймворка Telepathy нам нужно, чтобы в прокси-объектах наших объектов было известно, какие есть интерфейсы, нам нужно, чтобы можно было получать описание базовых свойств для данного типа объектов (например, тип канала, цель использования канала и кто был инициатором его открытия), а также нам необходимо определять и отслеживать состояние или статус объекта (например, статус соединения).

Таким образом, для всех прокси-объектов существует понятие готовности. С помощью вызова метода в прокси-объекте, вы можете асинхронно подготовить информацию о состоянии этого объекта и, при этом вы будете уведомлены, когда информаци о состоянии объекта будет подготовлена, а это будет означать, что объект готов к использованию.

Поскольку не все клиентские программы реализованы и не все из них могут нас интересовать, все возможности данного объекта, подготавливаемые для объекта определенного типа, выделяются в ряд допустимых возможностей. В каждом объекте реализуется базовая возможность, в которой будет подготовлена наиболее важная информация об объекте (например, его свойство Interfaces и его начальное состояние), плюс ряд необязательных возможностей для дополнительного состояния, которое может включать в себя дополнительные свойства или возможность отслеживания состояние объекта. Конкретными примерами дополнительных возможностей, которые вы можете подготавливать на различных прокси, являются информация о контакте, свойства объекта, геолокационная информация, состояния чата (например, «Пользователь печатает сообщение...») и аватары пользователей.

Например, в прокси объектах соединений обычно имеется:

Программист запрашивает объект, который нужно будет подготовить, предоставляя список возможностей, которые ему интересны, и функцию обратного вызова, которая будет вызвана, когда эти возможности станут готовыми. Если все возможности уже подготовлены, то функция обратного вызова может вызываться сразу, в ином случае функция обратного вызова будет вызвана тогда, когда все указанные возможности будут найдены.

20.5. Устойчивость к ошибкам

Одним из ключевых преимуществ фреймворка Telepathy является его устойчивость к ошибкам (robustness). Компоненты являются модулями, поэтому проблемы в одном компоненте не должны выводить из строя всю систему. Ниже перечисляются некоторые из особенностей фреймворка Telepathy, которые делают его устойчивым к ошибкам:

20.6. Расширение Telepathy: механизм sidecars

Не смотря на то, что спецификация Telepathy старается охватить широкий спектр возможностей, предоставляемых коммуникационными протоколами, некоторые протоколы сами являются расширяемыми [4]. Разработчикам фреймворка Telepathy хотели сделать так, чтобы можно было использовать такие расширения без необходимости расширения самой спецификации Telepathy. Это было сделано с помощью механизма sidecars.

Механизм sidecars обычно реализуется с помощью плагинов менеджера соединений. Клиентская программа вызывает метод, запрашивающий sidecar, в котором реализован данный интерфейс шины D-Bus. Например, в чьей-то реализации списков приватности XEP-0016 может быть реализован интерфейс с названием com.example.PrivacyLists. Затем метод вернет объект D-Bus, предоставленный плагином, который должен реализовывать этот интерфейс (и, возможно, другие интерфейсы). Объект существует вместе с основным объектом соединения (наподобие мотоциклетной коляски - «sidecar», которая крепится сбоку мотоцикла).

История механизма sidecars

В самом начале разработки фреймворка Telepathy, в проекте One Laptop Per Child («Каждому ребенку свой ноутбук») для совместного обмена информацией между устройствами потребовалась поддержка своих собственных расширений протокола XMPP (XEPs). Они были добавлены прямо в Telepathy-Gabble (менеджер соединений для XMPP) и предоставлялись через недокументированные интерфейсы объекта соединения. В конце концов, всё больше и больше разработчиков хотело поддерживать конкретные расширения XEP, для которых не было аналогов в других коммуникационных протоколах. Было решено, что для плагинов необходим новый, более общий интерфейс.

20.7. Беглый взгляд внутрь менеджера соединений

Большинство менеджеров соединений написаны с использованием языковых привязок к C/GLib, причем был разработан ряд высокоуровневых базовых классов, которые делают более простым написание менеджеров соединений. Как упоминалось ранее, объекты D-Bus публикуются из объектов программы, в которых реализован ряд интерфейсов, отображаемых в интерфейсы D-Bus. В библиотеке Telepathy-GLib представлены базовые объекты для реализации объектов менеджеров соединений, соединений и каналов. В этой библиотеке предлагается интерфейс реализации менеджера каналов. Менеджеры каналов являются фабриками, которые можно использоваться для того, чтобы с помощью BaseConnection получать экземпляры объектов каналов и управлять ими при публикации их на шине.

В привязках также предоставляется то, что называется миксинами (mixins). Их можно добавлять к классу с тем, чтобы с помощью одного и того же механизма предоставлять дополнительные функциональные возможности, абстрагировать спецификации API, а также обеспечивать обратную совместимость между новыми и устаревшими версиями API. Самым распространенным примером использования является миксин, который добавляет к объекту интерфейс свойств D-Bus. Также есть миксины для реализации интерфейсов ofdT.Connection.Interface.Contacts и ofdT.Channel.Interface.Group и миксины, позволяющие с помощью одного и того же набора методов реализовывать старые и новые интерфейсы присутствия и старые и новые интерфейсы поддержки текстовых сообщений.

Рис.20.5: Пример архитектуры менеджера соединений

Использование миксинов для решения проблем с ошибкой в API

Одним из мест, где миксины были использованы для исправления ошибки в спецификации Telepathy, является TpPresenceMixin. Исходный интерфейс, предложенный в Telepathy (odfT.Connection.Interface.Presence), был сильно переусложнен, его было тяжело использовать при реализации соединений и клиентских программ, и в нем была представлена та функциональность, которая отсутствовала в большинстве коммуникационных протоколах, а в остальных - использовалась редко. Этот интерфейс был заменен более простым интерфейсом (odfT.Connection.Interface.SimplePresence), в котором была представлена функциональность, необходимая пользователям, и которая которая действительно была реализована в менеджерах соединений.

В миксине присутствия реализованы оба интерфейса соединения, позволяющие старым клиентским приложениям продолжать работать, но на функциональном уровне более простого интерфейса.

20.8. Усвоенные уроки

Фреймворк Telepathy является превосходным примером того, как можно поверх D-Bus создать модульный и гибкий интерфейс API. Он показывает, как нужно поверх D-Bus разрабатывать расширяемый фреймворк, состоящий из нескольких слабо связанных между собой частей. Причем такой, для которого не нужен центральный управляющий демон, и в котором отдельные компоненты можно запускать независимо друг от друга и без потери данных в других компонентах. Telepathy также показывает, как можно эффективно использовать шину D-Bus, минимизируя объем трафика, передаваемого через шину.

Рарзаботка Telepathy была итеративной, постепенно улучшавшей использование шины D-Bus. Были ошибки и были усвоены уроки. Далее перечисляется наиболее важное из того, что мы усвоили, когда разрабатывали архитектуру Telepathy:

Примечания

  1. http://telepathy.freedesktop.org/ или смотрите руководство разработчика на http://telepathy.freedesktop.org/doc/book/
  2. http://telepathy.freedesktop.org/spec/
  3. Далее мы будем сокращать /org/freedesktop/Telepathy/ и org.freedesktop.Telepathy как \code{ofdT} с целью экономии места.
  4. Например, XMPP (Extensible Messaging and Presence Protocol, т.е. расширяемый протокол обмена сообщениями и информацией о присутствии.)

На главную -> MyLDP -> Тематический каталог ->

Фреймворк Thousand Parsec

Глава 21 из 1 тома книги "Архитектура приложений с открытым исходным кодом".

Оригинал: "Thousand Parsec", глава из книги "The Architecture of Open Source Applications"
Авторы: Alan Laudicina and Aaron Mavrinac
Дата публикации: 2012 г.
Перевод: Н.Ромоданов
Дата перевода: март 2013 г.

Creative Commons

Перевод был сделан в соответствие с лицензией Creative Commons. С русским вариантом лицензии можно ознакомиться здесь.

На главную -> MyLDP -> Тематический каталог ->

Фреймворк Violet

Глава 22 из книги "Архитектура приложений с открытым исходным кодом", том 1.

Оригинал: "Violet", глава из книги "The Architecture of Open Source Applications"
Автор: Cay Horstmann
Дата публикации: 2012 г.
Перевод: Н.Ромоданов
Дата перевода: апрель 2013 г.

Creative Commons. Перевод был сделан в соответствие с лицензией Creative Commons. С русским вариантом лицензии можно ознакомиться здесь.

В 2002 году я написал для студентов учебник по объектно-ориентированному проектированию и использованию шаблонов [Hor05]. Как и в случае многих книг, его появление было вызвано разочарованием, связанным с канонической учебной программой. Часто студенты информатики обучаются проектированию классов на первом курсе программирования, а затем у них отсутствует всякая дальнейшая практика объектно-ориентированного проектирования вплоть до курса инженерии программного обеспечения более высокого уровня. В этом курсе студенты спешно в течение пары недель проходят UML и шаблоны проектирования, что дает не более чем иллюзию знания. Моя книга была написана в поддержку односеместрового курса для студентов, имеющих опыт программирования на языке Java и умеющих использовать основные структура данных (обычно курсы CS1/CS2 на базе Java). В книге рассматриваются принципы объектно-ориентированного проектирования и использование шаблонов проектирования в контексте знакомых ситуаций. Например, шаблон проектирования Decorator вводится вместе с классом JScrollPane на Swing в надежде на то, что этот пример будет более запоминающимся, чем канонический пример потоков Java.

Рис.22.1: Диаграмма объектов в Violet

Мне для книги нужно было упрощенное подмножество языка UML: диаграммы классов, диаграммы последовательностей и вариант диаграмм объектов Java, в которых указываются ссылки на объект (рис. 22.1). Я также хотел, чтобы студенты рисовали свои собственные диаграммы. Тем не менее, коммерческие варианты, например, Rational Rose, были не только дороги, но и громоздки для изучения и использования [Shu05], а альтернативные варианты с открытым исходным кодом, в которых диаграммы задавались с помощью текстовых объявлений, а не обычным щелчком мыши, которые были доступны в то время, были слишком ограниченными или имели ошибки, не позволяющие их использовать. В частности, в ArgoUML были серьезные проблемы с диаграммами последовательностей.

Я решил попробовать свои силы в реализации простейшего редактора, который (а) будет полезен студентам и (б) будет примером фреймворка, с которым студенты смогут разобраться и смогут его модифицировать. Так родился редактор Violet.

22.1. Введение в Violet

Violet является легковесным редактором языка UML, предназначена для студентов, преподавателей и авторов, которым нужно быстро создавать простые диаграммы UML. Он очень прост в освоении и использовании. Он рисует диаграммы классов, последовательностей, состояний, объектов и сценариев использования (use-case). (С тех времен были добавлены другие типы диаграмм). Это кросс-платформенное программное обеспечение с открытым исходным кодом. В качестве своего ядра Violet использует простой, но гибкий фреймворк работы с графами, который позволяет в полной мере использовать возможности графики Java 2D API.

Пользовательский интерфейс Violet преднамеренно простой. Вам для того, чтобы вводить атрибуты и методы, не придется проходить через утомительную последовательность диалогов. Вместо этого, вы просто набираете их в текстовом поле. С помощью нескольких щелчков мыши, вы можете быстро создавать привлекательные и полезные диаграммы.

Violet не пытается стать программой для использования UML промышленного уровня. Вот некоторые возможности, отсутствующие в Violet:

(Попытка решить некоторые из этих ограничений позволило создать хорошие студенческие проекты).

Когда Violet создал культ дизайнеров, которые хотели чего-то большего, чем просто набросок на салфетке, но менее сложного, чем инструментальные средства UML промышленного уровня, я опубликовал код в SourceForge под лицензией GNU General Public License. Начиная с 2005 года к проекту присоединился Александр Пелегрин (Alexandre de Pellegrin), предложивший плагин для Eclipse и более красивый пользовательский интерфейс. С тех пор он сделал в архитектуре множество изменений и в настоящее время он является основный разработчиком, сопровождающим проект (primary maintainer).

В этой статье я рассмотрю некоторые из исходных архитектурных решений, выбранных в Violet, а также покажу их эволюцию. Часть статьи сосредоточена на вопросах редактирования графов, но и другие вопросы, например, использование свойств JavaBeans и хранение результатов, архитектура Java WebStart и плагины, должны быть интересны для всех.

22.2. Графический фреймворк

Violet базируется на универсальном фреймворке редактирования графов, который может выдавить изображения и позволяет редактировать узлы и ребра изображений произвольной формы. Редактор Violet для UML использует узлы для отображения классов, объектов, границ активации (в диаграммах последовательностей), и так далее, а ребра — для различных дуг в диаграммах UML. Другой экземпляр графического фреймворка может отображать диаграммы «сущность - отношение» или синтаксические диаграммы.

Рис.22.2: Простой экземпляр фреймворка редактирования

Для того чтобы проиллюстрировать фреймворк, рассмотрим редактор для очень простых графов с черно-белыми круглыми узлами и прямыми ребрами (рис.22.2). В классе SimpleGraph определяются прототипные объекты для типов узлов и ребер, иллюстрирующие шаблон прототипорования prototype:

public class SimpleGraph extends AbstractGraph
{
  public Node[] getNodePrototypes()
  {
    return new Node[]
    {
      new CircleNode(Color.BLACK),
      new CircleNode(Color.WHITE)
    };
  }
  public Edge[] getEdgePrototypes()
  {
    return new Edge[]
    {
      new LineEdge()
    };
  }
}

Прототипные объекты используются для рисования кнопок узлов и ребер в верхней части рисунка 22.2. Они клонируются всякий раз, когда пользователь добавляет в граф новый экземпляр узла или ребра. Узел Node и ребро Edge являются интерфейсами со следующими ключевыми методами:

Рис.22.3: Поиск точки присоединения на границе формы узла Node

Удобные классы AbstractNode и AbstractEdge реализуют ряд таких методов, а классы RectangularNode и SegmentedLineEdge обеспечивают всю полную реализацию прямоугольных узлов со строкой-заголовком и ребрами, которые состоят из отрезков прямой.

В случае нашего простого редактора графов нам нужны подклассы CircleNode и LineEdge, в которых есть метод draw, метод contains и метод getConnectionPoint, в которых описывается форма границы узла. Ниже приведен код, а на рис 22,4 показана диаграмма классов для этих классов (нарисованных, конечно, с помощью Violet).

public class CircleNode extends AbstractNode
{
  public CircleNode(Color aColor)
  {
    size = DEFAULT_SIZE;
    x = 0;
    y = 0;
    color = aColor;
  }

  public void draw(Graphics2D g2)
  {
    Ellipse2D circle = new Ellipse2D.Double(x, y, size, size);
    Color oldColor = g2.getColor();
    g2.setColor(color);
    g2.fill(circle);
    g2.setColor(oldColor);
    g2.draw(circle);
  }

  public boolean contains(Point2D p)
  {
    Ellipse2D circle = new Ellipse2D.Double(x, y, size, size);
    return circle.contains(p);
  }

  public Point2D getConnectionPoint(Point2D other)
  {
    double centerX = x + size / 2;
    double centerY = y + size / 2;
    double dx = other.getX() - centerX;
    double dy = other.getY() - centerY;
    double distance = Math.sqrt(dx * dx + dy * dy);
    if (distance == 0) return other;
    else return new Point2D.Double(
      centerX + dx * (size / 2) / distance,
      centerY + dy * (size / 2) / distance);
  }

  private double x, y, size, color;
  private static final int DEFAULT_SIZE = 20;
}

public class LineEdge extends AbstractEdge
{
  public void draw(Graphics2D g2)
  { g2.draw(getConnectionPoints()); }

  public boolean contains(Point2D aPoint)
  {
    final double MAX_DIST = 2;
    return getConnectionPoints().ptSegDist(aPoint) < MAX_DIST;
  }
}

Рис.22.4: Диаграмма классов для простого графа

В целом, Violet предоставляет простой фреймворк для создания редакторов графов. Чтобы получить экземпляр редактора, определяются классы узлов и ребер и предоставляются методы в классе графа, с помощью которых можно получить прототипы объектов узел и ребро.

Конечно, есть и другие графовые фреймворки, например, JGraph [Ald02] и JUNG2. Однако эти фреймворки гораздо более сложные и они являются фреймворками для рисования графов, а не для создания приложений, которые рисуют графы.

22.3. Использование свойств JavaBeans

В золотые деньки использования языка Java на клиентской стороне были разработаны спецификации JavaBeans с целью обеспечить портируемые механизмы для редактирования компонент графического интерфейса GUI в визуальной среде сборки приложений. Предполагалось, что сторонний компонент GUI может быть помещен в любую среду сборки GUI, где его свойства могут быть настроены таким же самым образом, как и стандартные кнопки, текстовые компоненты, и так далее.

В языке Java нет нативных свойств. Вместо этого, могут использоваться свойства JavaBeans, доступ к которым осуществляется при помощи пар методов get и set или указываемых в сопутствующих классах BeanInfo. Кроме того, для визуального редактирования значения свойства могут быть определены редакторы свойств. В JDK даже есть несколько базовых редакторов свойств, например, для типа java.awt.Color.

Фреймворк Violet в полной мере использует спецификации JavaBeans. Например, в классе CircleNode можно пользоваться свойством «цвет» с помощью следующих двух методов:

public void setColor(Color newValue)
public Color getColor()

Ничего большего не требуется. Редактор графов теперь может редактировать цвет узла круглых узлов (рис.22.5).

Рис.22.5: Редактирование цвета кружков с помощью редактора цвета, предлагаемого в JavaBeans по умолчанию

22.4. Хранение полученных результатов

Как и любая программа-редактор, Violet должен сохранять в файле творения пользователя и загружать их позже. Я должен был обратиться к спецификации XMI [3], которая была разработана как общий формат обмена для моделей UML. Я посчитал его громоздким, запутанным, и трудным в использовании. Не думаю, что я был одинок - XMI имел репутацию плохо совместимого языка даже для самых простых моделей [PGL+05].

Я решил просто использовать сериализацию языка Java, однако из-за этого было трудно читать старые версии сериализованного объекта, реализация которого с течением времени изменялась. Эту проблему также предвидели архитекторы JavaBeans, которые разработали формат стандарта XML для долгосрочного хранения данных [4]. Объект языка Java, в случае использования Violet, - диаграмма UML, которая сериализуется как последовательность операторов для построения и модификации объекта. Например:

<?xml version="1.0" encoding="UTF-8"?>
<java version="1.0" class="java.beans.XMLDecoder">
 <object class="com.horstmann.violet.ClassDiagramGraph">
  <void method="addNode">
   <object id="ClassNode0" class="com.horstmann.violet.ClassNode">
    <void property="name">…</void>
   </object>
   <object class="java.awt.geom.Point2D$Double">
    <double>200.0</double>
    <double>60.0</double>
   </object>
  </void>
  <void method="addNode">
   <object id="ClassNode1" class="com.horstmann.violet.ClassNode">
    <void property="name">…</void>
   </object>
   <object class="java.awt.geom.Point2D$Double">
    <double>200.0</double>
    <double>210.0</double>
   </object>
  </void>
  <void method="connect">
   <object class="com.horstmann.violet.ClassRelationshipEdge">
    <void property="endArrowHead">
     <object class="com.horstmann.violet.ArrowHead" field="TRIANGLE"/>
    </void>
   </object>
   <object idref="ClassNode0"/>
   <object idref="ClassNode1"/>
  </void>
 </object>
</java>

Когда класс XMLDecoder читает этот файл, он выполняет эти инструкции (для простоты имена пакетов опущены).

ClassDiagramGraph obj1 = new ClassDiagramGraph();
ClassNode ClassNode0 = new ClassNode();
ClassNode0.setName(…);
obj1.addNode(ClassNode0, new Point2D.Double(200, 60));
ClassNode ClassNode1 = new ClassNode();
ClassNode1.setName(…);
obj1.addNode(ClassNode1, new Point2D.Double(200, 60));
ClassRelationShipEdge obj2 = new ClassRelationShipEdge();
obj2.setEndArrowHead(ArrowHead.TRIANGLE);
obj1.connect(obj2, ClassNode0, ClassNode1);

До тех пор, пока не изменяется семантика конструкторов, свойств и методов, новая версия программы может прочитать файл, который был подготовлен при помощи старой версии.

Создание таких файлов осуществляется сравнительно просто. Кодировщик автоматически перечисляет свойства каждого объекта и записывает инструкции set для тех значений свойств, которые отличаются от используемых по умолчанию. Большинство базовых типов данных обрабатываются платформой Java; но я должен был предоставить специальные обработчики для Point2D, Line2D и Rectangle2D. Самое главное, что кодировщик должен знать, что граф может быть сериализован как последовательность вызовов методов addNode и connect:

encoder.setPersistenceDelegate(Graph.class, new DefaultPersistenceDelegate()
{
  protected void initialize(Class> type, Object oldInstance,
    Object newInstance, Encoder out)
  {
    super.initialize(type, oldInstance, newInstance, out);
    AbstractGraph g = (AbstractGraph) oldInstance;
    for (Node n : g.getNodes())
      out.writeStatement(new Statement(oldInstance, "addNode", new Object[]
      {
        n,
        n.getLocation()
      }));
    for (Edge e : g.getEdges())
      out.writeStatement(new Statement(oldInstance, "connect", new Object[]
      {
        e, e.getStart(), e.getEnd()
      }));
   }
 });

Как только кодировщик был сконфигурирован, сохранение графа стало выглядеть максимально просто:

encoder.writeObject(graph);

Поскольку декодировщик просто выполняет инструкции, для него настройка не требуется. Графы читаются просто с помощью:

Graph graph = (Graph) decoder.readObject();

Этот подход работает исключительно хорошо с многочисленными версиями Violet с одним исключением. Последний рефакторинг изменил некоторые имена пакетов и тем самым нарушил обратную совместимость. Одним из вариантов было бы хранить классы в первоначальных пакетах даже если они уже не соответствуют новой структуре пакетов. Вместо этого, разработчик, осуществляющий сейчас сопровождение проекта, предложил использовать трансформер XML для переписывания имен пакетов при чтении устаревших файлов.

22.5. Java WebStart

Java WebStart это технология для запуска приложений из веб-браузера. Сервер, осуществляющий развертывание приложения, отправляет файл JNLP, который запускает вспомогательные приложения в браузере, загружающим и запускающим программу Java. Приложение может иметь цифровую подпись, в этом случае пользователь должен иметь доступ к сертификату, или оно может не иметь подписи и в этом случае программа запускается в изолированной среде, которая имеет немного больше прав, чем песочница апплетов.

Я не думаю, что конечные пользователи могут или должны достоверно разбираться в достоверности цифрового сертификата и его последствиях для безопасности. Одной из сильных сторон платформы Java является ее безопасность, и я понимаю, что важно это использовать.

Песочница Java WebStart является достаточно мощной с тем, чтобы позволить пользователям выполнять полезную работу, в том числе загружать и сохранять файлы, а также пользоваться печатью. Эти операции, с точки зрения пользователя, выполняются надежно и удобно. Пользователь будет предупрежден о том, что приложение хочет получить доступ к локальной файловой системе, а затем он сам выберет файл для чтения или записи. Приложение лишь примет потоковый объект, не имея возможности во время выбора файла заглянуть в файловую систему.

Раздражает то, что когда приложение работает под WebStart, разработчик должен писать специальный код для взаимодействия с FileOpenService и FileSaveService, и еще больше раздражает то, что нет какого-нибудь вызова WebStart API, позволяющего выяснить, было ли приложение запущено с помощью WebStart.

Кроме того, сохранение пользовательских настроек необходимо реализовывать двумя способами: с помощью Java preferences API в случае, когда приложение работает нормально, или с помощью сервиса WebStart preference в случае, когда приложение запускается в рамках WebStart. Процесс печати, с другой стороны, полностью прозрачный для прикладного программиста.

С тем, чтобы жизнь программистам сделать существенно проще, в Violet поверх этих сервисов предоставляются простые слои абстракции. Например, файл открывается следующим образом:

FileService service = FileService.getInstance(initialDirectory);
  // detects whether we run under WebStart
FileService.Open open = fileService.open(defaultDirectory, defaultName,
  extensionFilter);
InputStream in = open.getInputStream();
String title = open.getName();

Интерфейс FileService.Open реализуется с помощью двух классов: обертка поверх JFileChooser или FileOpenService для JNLP.

понравился и игнорировался повсюду. Большинство проектов просто используют собственный сертификат для своих приложений WebStart, что не обеспечивает какой-либо безопасности. Неприятно, почему разработчики проектов с открытым исходным кодом должны находиться в песочнице JNLP для того, чтобы безопасно опробовать проект.

22.6. Java 2D

В Violet интенсивно используется библиотека Java2D, один из менее известных драгоценных камней в Java API. Каждый узел и ребро имеет метод getShape, который выдает java.awt.Shape - общий интерфейс всех форм Java2D. В этом интерфейсе реализованы прямоугольники, круги, пути и их объединения, пересечения и разности. Класс GeneralPath используется для создания фигур, которые состоят из сегментов произвольных линий и квадратичных/кубических кривых, например, прямых и закругленных стрелок.

Чтобы оценить гибкость Java2D API, рассмотрим следующий код, рисующий тени в методе AbstractNode.draw:

Shape shape = getShape();
if (shape == null) return;
g2.translate(SHADOW_GAP, SHADOW_GAP);
g2.setColor(SHADOW_COLOR);
g2.fill(shape);
g2.translate(-SHADOW_GAP, -SHADOW_GAP);
g2.setColor(BACKGROUND_COLOR);
g2.fill(shape);

Несколько строк кода создают тени для любых форм, даже для тех, которые разработчик может добавить на более позднем этапе.

Конечно, в Violet сохраняются растровые изображения в любом формате, который поддерживается пакетом javax.imageio; то есть, GIF, PNG, JPEG, и так далее. Когда мой издатель спросил меня о векторных изображениях, я обнаружил еще одно преимущество библиотеки Java 2D. При печати на PostScript-принтере, Java2D операции преобразуются в операции векторной графики на языке PostScript. Если печатать в файл, результат можно использовать с такими программами, как ps2eps, а затем его можно импортировать в Adobe Illustrator или Inkscape. В следующем коде компонент comp из Swing, метод которого paintComponent используется для рисования графа:

DocFlavor flavor = DocFlavor.SERVICE_FORMATTED.PRINTABLE;
String mimeType = "application/postscript";
StreamPrintServiceFactory[] factories;
StreamPrintServiceFactory.lookupStreamPrintServiceFactories(flavor, mimeType);
FileOutputStream out = new FileOutputStream(fileName);
PrintService service = factories[0].getPrintService(out);
SimpleDoc doc = new SimpleDoc(new Printable() {
  public int print(Graphics g, PageFormat pf, int page) {
      if (page >= 1) return Printable.NO_SUCH_PAGE;
      else {
        double sf1 = pf.getImageableWidth() / (comp.getWidth() + 1);
        double sf2 = pf.getImageableHeight() / (comp.getHeight() + 1);
        double s = Math.min(sf1, sf2);
        Graphics2D g2 = (Graphics2D) g;
        g2.translate((pf.getWidth() - pf.getImageableWidth()) / 2,
            (pf.getHeight() - pf.getImageableHeight()) / 2);
        g2.scale(s, s);

        comp.paint(g);
        return Printable.PAGE_EXISTS;
      }
  }
}, flavor, null);
DocPrintJob job = service.createPrintJob();
PrintRequestAttributeSet attributes = new HashPrintRequestAttributeSet();
job.print(doc, attributes);

В начале, я был обеспокоен тем, что использование общих форм может привести к снижению производительности, но, как оказалось, это не так. Операция вырезания фрагментов работает достаточно хорошо, поскольку в действительности выполняются только те операции, которые необходимы для обновления текущей видимой области.

22.7. Это не фреймворк приложений Swing

В большинстве фреймворков графического пользовательского интерфейса GUI заложена некоторая идея о том, как в приложении происходит управление набором документов с использованием меню, панелей инструментов, строк состояний и т. д. Однако это никогда не было частью Java API. JSR 296 [5] должен был предоставить базовый фреймворк для приложений Swing, но в настоящее время он не действует. Таким образом, у авторов приложений Swing есть два варианта: самостоятельно изобретать велосипед или полагаться на фреймворк сторонних разработчиков. В то время, когда был написан Violet, первыми фреймворками приложений, которые обычно выбирались, были платформы Eclipse и NetBeans, каждая из которых в то время казалась слишком утяжеленной. (В настоящее время есть более широкий выбор, в том числе форки JSR 296, например, GUTS [6]). Таким образом, в Violet потребовалось заново изобретать механизмы для работы с меню и внутренними фреймами.

В Violet вы можете указать пункты меню в файлах свойств, например:

file.save.text=Save
file.save.mnemonic=S
file.save.accelerator=ctrl S
file.save.icon=/icons/16x16/save.png

Утилита method создает пункт меню используя для этого префикс (здесь — file.save). Суффиксы .text, .mnemonic и так далее, являются тем, что сегодня назвали бы «конфигурационным соглашением». Очевидно, что использование файлов ресурсов для описания этих параметров гораздо лучше, чем создание меню с вызовами API, поскольку здесь проще применять локализацию. Я повторно использовал этот механизм в другом проекте с открытым исходным кодом - в среде GridWorld, предназначенной для изучения информатики в средней школе [7].

Приложения, такие как Violet, позволяют пользователям открывать несколько «документов», в каждой из которых есть граф. Когда был написан первый вариант Violet, то тогда все еще применялся обычный многодокументный интерфейс (MDI). В MDI основной фрейм имел меню, и каждый документа отображается во внутреннем фрейме, имеющим название, но не имеющим меню. Каждый внутренний фрейм находился внутри основного фрейма и пользователь мог изменить его размер или его минимизировать. Внутренние фреймы могли располагаться каскадно или плитками.

Многим разработчикам не нравится MDI, и поэтому этот стиль пользовательского интерфейса вышел из моды. Какое-то время предпочтение отдавалось однодокументному интерфейсу (SDI), в котором приложение отображает несколько фреймов верхнего уровня; возможно предпочтение отдавалось потому, что этими фреймами можно было управлять с помощью инструментальных средств стандартного окна операционной системы. Когда стало ясно, что наличие большого количества окон верхнего уровня в конце концов не так уж и хорошо, появился интерфейс со вкладками, в котором несколько документов снова находятся в одном фрейме, но теперь все они отображаются в полном размере и выбираются с помощью вкладок. Это не позволяет пользователям сравнивать два документа, помещая их рядом друг с другом, но, кажется, этот подход победил.

Первоначальная версия Violet использовала интерфейс MDI. В Java API есть возможности использовать внутренние фреймы, но я должен был добавить поддержку для размещения фреймов в виде плиток и для каскадного размещения. Александр перешел на интерфейс с вкладками, который несколько лучше поддерживается в Java API. Было бы желательно иметь фреймворк приложения, в котором политика отображения документа была бы прозрачной для разработчика и, возможно, выбиралась бы пользователем.

Александр также добавил поддержку боковых панелей, панели приглашения и заставки. В идеале все это должно быть частью фреймворка приложений на Swing.

22.8. Операции Undo/Redo

Реализация множественных операций undo/redo кажется сложной задачей, но в пакете Swing undo ([Top00], глава 9) приведены хорошие рекомендации, относящиеся к архитектуре. Менеджер UndoManager управляет стеком объектов UndoableEdit. В каждом из них есть метод undo, который отменяет эффект операции редактирования, и метод redo, который отменяет действие undo (то есть, восстанавливает первоначальную операцию редактирования). CompoundEdit представляет собой последовательность операций UndoableEdit, которые должны отменяться или восстанавливаться в полном объеме. Вам предлагается определить небольшие, атомарные операции редактирования (например, добавление или удаление одного ребра или узла в случае использования графа), которые, по мере необходимости, группируются в составные операции редактирования.

Задача состоит в том, чтобы определить небольшой набор атомарных операций, каждую из которых можно легко отменить. В Violet это следующие операции:

Для каждой их этих операций есть понятное действие отмены undo. Например, операция undo для добавления узла является удалением узла. Операция undo для перемещения узла означает перемещение в обратном направлении.

Рис.22.6: Операция Undo должна отменять структурные изменения, сделанные в модели

Отметим, что эти атомарные операции не совпадают с действиями в пользовательском интерфейсе или с методами интерфейса Graph, которые вызываются с помощью действий пользовательского интерфейса. Например, рассмотрим диаграмму последовательности на рисунке 22.6, и предположим, что пользователь перетаскивает вправо с помощью мыши блок активации. При отпускании кнопки мыши, вызывается метод:

public boolean addEdgeAtPoints(Edge e, Point2D p1, Point2D p2)

Этот метод добавляет ребро, но может также осуществлять другие операции, которые указываются в подклассах Edge и Node. В этом случае блок активации будет добавлен к линии, расположенной справа. При выполнении операции отмены undo нужно также будет удалить блок активация. Таким образом, в модели (в нашем случае в графе) должны записываться структурные изменения, которые могут быть отменены. Для этого недостаточно пользоваться только контроллером операций.

Как предусмотрено в пакете Swing undo, классы graph, node и edge должны всякий раз, когда происходит структурное изменение, посылать уведомления UndoableEditEvent в менеджер уведомлений UndoManager. В Violet используется более общая схема, в которой граф сам управляет методами - слушателями (listeners) следующего интерфейса:

public interface GraphModificationListener
{
  void nodeAdded(Graph g, Node n);
  void nodeRemoved(Graph g, Node n);
  void nodeMoved(Graph g, Node n, double dx, double dy);
  void childAttached(Graph g, int index, Node p, Node c);
  void childDetached(Graph g, int index, Node p, Node c);
  void edgeAdded(Graph g, Edge e);
  void edgeRemoved(Graph g, Edge e);
  void propertyChangedOnNodeOrEdge(Graph g, PropertyChangeEvent event);
}

Фреймворк устанавливает слушателя в каждом графе; такой слушатель является мостиком к менеджеру операций undo. Чтобы поддерживать операцию undo, нужно дополнительно переопределить общую поддержку слушателей модели — операции, выполняемые с графом, должны непосредственно взаимодействовать с менеджером undo. Но мне бы также хотелось поддерживать совместимые экспериментальные функции редактирования.

Если вы хотите поддерживать операции undo/redo в вашем приложении, подумайте внимательно об атомарных операциях в модели (а не вашего пользовательского интерфейса). Когда в модели происходят структурные изменения, должны вырабатываться события и менеджер Swing undo должен собирать и группировать такие события.

22.9. Архитектура плагинов

Программисту, знакомому с графикой 2D, будет несложно добавить в Violet новый тип диаграммы. Например, диаграммы активностей были предоставлены третьими лицами. Когда мне потребовалось создать синтаксические диаграммы и диаграммы ER, я решил, что быстрее написать расширения для Violet, а не возиться с Visio или Dia. (На реализацию диаграмм каждого типа потребовалось по одному дню).

Эти реализации не требуются знание всего фреймворка Violet. Необходимы только интерфейсы классов graph, node и edge и соответствующие их реализации. Чтобы упростить задачу разработчикам, я, самостоятельно отделив их от фреймворка, разработал простую архитектуру плагинов.

Конечно, в многих программах есть архитектура плагинов, причем достаточно сложная. Когда кто-то предположил, что Violet должен поддерживать OSGi, я содрогнулся и вместо этого реализовал более простую вещь, которая работает.

Авторы просто создают файл JAR со своими реализациями классов graph, node и edge и помещают его в папку плагинов. Когда Violet запускается, он загружает эти плагины с помощью класса Java ServiceLoader. Этот класс был разработан для загрузки сервисов, например, драйверов JDBC. ServiceLoader загружает файлы JAR, в которых должен быть предоставлен класс, реализующий данный интерфейс (в нашем случае интерфейс Graph).

Каждый файл JAR должен иметь подкаталог META-INF/services, содержащий файл, имя которого является полным квалифицированным именем класса интерфейса (например, com.horstmann.violet.Graph), и в котором в каждой строке указывается имя реализации каждого класса. ServiceLoader строит загрузчик классов для каталога плагинов и загружает все плагины:

ServiceLoader<Graph> graphLoader = ServiceLoader.load(Graph.class, classLoader);
for (Graph g : graphLoader) // ServiceLoader&ltGraph> implements Iterable&ltGraph>
  registerGraph(g);

Это простое, но полезное средство стандартного языка Java, которое может оказаться ценным для ваших собственных проектов.

22.10. Заключение

Как и многие другие проекты с открытым исходным кодом, проект Violet родился вследствие неудовлетворенных потребностей - рисовать простые диаграммы UML с минимальной суетой. Violet стал возможным благодаря удивительной широте платформы Java SE, и в нем используется набор разнообразных технологий, которые являются частью этой платформы. В этой статье я описал, как в Violet используются технологии Java Beans, хранения данных, Java Web Start, Java 2D, Swing Undo / Redo и средства загрузки сервисов. Не всегда достаточно ясно, как пользоваться этими технологиями в качестве базы Java и Swing, но они могут значительно упростить архитектуру настольных приложений. Они позволили мне, первоначально единственному разработчику, создать в течение нескольких месяцев успешное приложение, работая над ним неполный рабочий день. Использование этих стандартных механизмов позволило предоставить другим разработчикам возможность самостоятельно улучшать Violet, а также применять его отдельные части в своих собственных проектах.

Примечания

  1. В то время я еще не знал о замечательной программе UMLGraph, созданной Диомидисом Спинелисом (Diomidis Spinellis) [Spi03]
  2. http://jung.sourceforge.net
  3. http://www.omg.org/technology/documents/formal/xmi.htm
  4. http://jcp.org/en/jsr/detail?id=57
  5. http://jcp.org/en/jsr/detail?id=296
  6. http://kenai.com/projects/guts
  7. http://horstmann.com/gridworld

На главную -> MyLDP -> Тематический каталог ->

Система VisTrails

Глава 23 из 1 тома книги "Архитектура приложений с открытым исходным кодом".

Оригинал: "VisTrails", глава из книги "The Architecture of Open Source Applications"
Авторы: Juliana Freire, David Koop, Emanuele Santos, Carlos Scheidegger, Claudio Silva, and Huy T. Vo
Дата публикации: 2012 г.
Перевод: Н.Ромоданов
Дата перевода: март 2013 г.

Creative Commons
Перевод был сделан в соответствие с лицензией Creative Commons. С русским вариантом лицензии можно ознакомиться здесь.

Система VisTrails [http://www.vistrails.org] является системой с открытым исходным кодом, с помощью которой поддерживаются исследования данных и их визуализация. В ее составе есть постоянно расширяющиеся полезные возможности, предоставляемые в системах научного анализа и визуализации данных. Как и системы научного анализа рабочих процессов, такие как Kepler и Taverna, система VisTrails позволяет в соответствие с набором правил задавать вычислительные процессы, в которых используются существующие приложения, слабо связанные ресурсы и библиотеки. Как и в системах визуализации, таких как AVS и ParaView, в системе VisTrails пользователям предлагаются современные технологии научной и информационной визуализации, позволяющие им исследовать и сравнить различные визуальные представления своих данных. В результате, пользователи могут создавать сложные процессы, которые включают в себя важные этапы научных исследований — от сбора данных и подготовки данных и до манипуляции с комплексным анализом и визуализацией, причем все это интегрировано в одну систему.

Отличительной особенностью системы VisTrails является ее инфраструктура для работы с информацией о происхождении данных. Система VisTrails позволяет по ходу исследовательской задачи собирать данные, получаемые на каждом из шагов, и вести подробную историю их получения. Для автоматизации повторяющихся задач традиционно используются рабочие процессы (workflow), но в приложениях, которые по своему характеру предназначены для исследований, нормой будут очень маленькие и повторяющиеся изменения в этих процессах. По мере того, как пользователь создает и оценивает гипотезы, связанные с его данными, создается ряд различных, но взаимосвязанных рабочих процессов, настройка которых происходит итеративно.

Система VisTrails была разработана для управления этими быстро эволюционирующими рабочими процессами: в ней поддерживается работа с информацией о происхождении продуцируемых данных (например, визуализация, графики), о рабочих процессах, в которых эти данные продуцируются, и о выполнении этих рабочих процессов. В системе также есть средства аннотации, так что пользователи могут автоматически собираемые данные дополнять описаниями.

Кроме возможности повторно воспроизводить результаты, система VisTrails упрощает доступ к информации о происхождении данных с помощью ряда операций и интуитивно понятного интерфейса, в котором пользователям предоставляется возможность совместно анализировать данные. Примечательно, что благодаря тому, что в системе есть возможность хранить временные результаты, в ней поддерживается работа с рефлексивными рассуждениями, позволяющими пользователям изучить действия, ведущие к результату, и отслеживать цепочки рассуждений как в прямом, так и в обратном направлении. Пользователи могут интуитивно понятным способом переходить от одной версии рабочего процесса к другой, отменять изменения, не теряя при этом результатов, визуально сравнить несколько рабочих процессов и одновременно показывать их результаты в таблице визуализации.

В VisTrails затронуты важные вопросы удобства использования системы (usability), препятствующие более широкому распространению систем на базе рабочих процессов и систем визуализации. Для удобства широкого круга пользователей, в том числе тех, у кого нет опыта в области программирования, в системе предоставляется возможность использовать серии действий и интерфейсы, упрощающие создание и использование рабочих процессов, в том числе позволяющие в интерактивном режиме по рекомендациям, предлагаемым системой , создавать и улучшать рабочие процессы по аналогии, запрашивать рабочие процессы по примерам, а также автоматически завершать разработку рабочих процессов. Мы также разработали новый фреймворк, позволяющий создавать специализированные приложения, которые могут легко устанавливаться и настраиваться конечными пользователями (не экспертами).

Возможность расширения системы VisTrails обусловлена ее инфраструктурой, которая позволяет пользователям легко интегрировать в систему другие инструментальные средства и библиотеки, а также быстро создать прототипы новых функций. Эта возможность сыграла важную роль в создании условий, позволяющих использовать систему в различных прикладных областях, в том числе в экологических исследованиях, психиатрии, астрономии, космологии, физики высоких энергий, квантовой физике и молекулярном моделировании.

Чтобы система VisTrails оставалась для всех бесплатной системой с открытым исходным кодом, мы создавали ее только с использованием бесплатных пакетов, имеющих открытый исходный код. Система VisTrails написана на языке Python, а в качестве инструментального средства графического интерфейса используется Qt (через привязку PyQt Python). Чтобы увеличить количество пользователей и расширить спектр используемых приложений, мы, помня о требовании переносимости, разрабатывали систему с нуля. Система VisTrails работает на платформах Windows, Mac и Linux.

Рис.23.1: Компоненты пользовательского интерфейса системы VisTrails

23.1. Обзор системы

Исследование данных является по своей сути творческим процессом, который требует от пользователей искать относящиеся к делу данные, собрать и визуализировать эти данные, общаться с коллегами, исследовать различные решения, и рассылать получаемые результаты. Если учесть размер данных и сложность анализа, которые обычно применяются в научных исследованиях, нужны инструментальные средства, которые будут лучшей поддержкой творчеству.

К таким инструментальным средствам предъявляются два неразрывно связанных друг с другом основных требования. Во-первых, важно иметь возможность с помощью формального описания определять исследовательские процессы, которые, в идеале, являются исполняемыми процессами. Во-вторых, для того, чтобы воспроизводить результаты этих процессов, а также рассуждения, делаемые на различных этапах последовательного решения проблемы, эти инструментальные средства должны иметь возможность систематическую собирать информацию о происхождении данных. Система VisTrails была разработана с учетом этих требований.

23.1.1. Рабочие процессы и системы на базе рабочих процессов

В системах с рабочими процессами (workflow systems) поддерживается создание конвейеров или рабочих процессов (workflows), с помощью которых происходит объединение нескольких инструментальных средств. Как таковые, они позволяют автоматизировать повторяющиеся задачи и повторно воспроизводить полученные результаты. Рабочие потоки являются скриптами командных оболочек, в которых можно быстро аенять примитивы. Они предназначены для решения широкого спектра задач, о чем свидетельствует ряд приложений, в которых применяются рабочие процессы, причем как коммерческие (например, Apple Mac OS X Automator и Yahoo! Pipes), так и академические (например, NiPype, Kepler и Taverna).

Рабочие процессы имеют ряд преимуществ в сравнение со скриптами и программами, написанными на языках высокого уровня. Они предлагают простую модель программирования, в которой последовательность задач создается с помощью подключения выходов одной задачи к входу другой. На рис 23.1 показан рабочий процесс который читает файл CSV, содержащий метеорологические наблюдения, и создает диаграммы разброса значений.

Такая упрощенная модель программирования позволяет системам на базе рабочих процессов использовать интуитивно понятный визуальный интерфейс программирования, что делает их более удобными для пользователей, не обладающих существенным опытом программирования. Рабочие процессы также имеют понятную структуру: их можно изображать в виде графов, в которых узлы представляют собой процессы (или модули) вместе с их параметрами, а с помощью ребер представлен поток данных между процессами. В примере на рис.23.1 модуль CSVReader получает в качестве параметра имя файла (/weather/temp_precip.dat), читает файл, и передает его содержимое в модули GetTemperature и GetPrecipitation, которые, в свою очередь, отправляют значения температуры и осадков в функцию matplotlib, с помощью которой создается график разброса значений.

Большинство систем с рабочими потоками предназначены для конкретных областей применения. Например, назначение системы Taverna — рабочие процессы из области биоинформатики, а система NiPype позволяет создавать рабочие потоки для нейровизуализации. Хотя в системе VisTrails поддерживается большинство функциональных возможностей, предоставляемых другими системами с рабочими потоками, в ней, система VisTrails благодаря тому, что в ней интегрировано большое количество инструментальных средств, библиотек и сервисов, разработана для поддержки исследовательских задач общего назначения из широкого спектра областей применения.

23.1.3. Информация о происхождении данных и рабочих процессов

В научном сообществе хорошо известно, что важно сохранять информацию о происхождении результатов (и вычислении данных). Эта информация (также называемая аудиторской информацией, информацией о происхождении или родословной), в которой указываются сведения о процессе и данных, используемых при получении результатов исследования. Информация о происхождении данных является ключевой при архивации данных, при оценке качества данных и определении авторства, при воспроизведении результатов, а также при их проверке.

Важным компонентом этих данных является информация о причинно следственных зависимостях (causality — казуальности), т. е. описание процесса (последовательности шагов), который вместе с входными данными и параметрами привел к получению конкретных результатов. Таким образом, в структуре информации об источнике данных отражается структура рабочего процесса (или набор процессов), используемого для получения данного результирующего набора данных.

На самом деле, в научных исследованиях катализатором широкого использования систем на базе рабочих процессов явилось то, что их легко было использовать для автоматического сохранения информации о происхождении данных. Ранее существовавшие системы были расширены с тем, чтобы можно было собирать информацию о происхождении данных, а система VisTrails была изначально разработана для поддержки работы с информацией о происхождении данных.

Рис.23.2: Информация о происхождении данных, снабженная аннотациями

23.1.3. Пользовательский интерфейс и базовые функциональные возможности

На рис.23.1 и рис.23.2 показаны различные компоненты пользовательского интерфейса системы. Пользователи с помощью редактора рабочих процессов Workflow Editor создают и редактируют рабочие процессы.

Чтобы создавать графы рабочих процессов, пользователи могут перетаскивать модули из реестра модулей Module Registry в канвас редактора рабочих процессов Workflow Editor. В системе VisTrails предложены наборы встроенных модулей, а пользователям также разрешается добавлять свои собственные модули (подробности смотрите в разделе 23.3). Когда модуль выбран, система VisTrails отображает его параметры (в области редактирования параметров Parameter Edits), и пользователь может задать их значения или их изменить.

По мере того, как спецификация рабочего процесса уточняется, система сохраняет изменения и представляет их пользователю для просмотра в окне дерева версий Version Tree View, которое будет описано ниже. Пользователи могут взаимодействовать с рабочими процессами и результатами их работы через таблицу VisTrails Spreadsheet. Каждая ячейка таблицы представляет собой окно, которое соответствует некоторому экземпляру рабочего процесса. На рис.23.1 результаты работы рабочего процесса показаны в редакторе рабочих процессов Workflow Editor, который показан в верхней левой ячейке таблицы. Пользователи могут либо напрямую изменять параметры рабочего процесса, либо синхронизировать их с содержимым различных ячеек электронной таблицы.

Окно дерева версий Version Tree View помогает пользователям переходить от одних версий рабочего процесса к другим. Как видно на рис.23.2, пользователь, щелкнув мышкой по узлу в дереве версий, может увидеть рабочий процесс, связанный с ним результат (режим предварительного просмотра Visualization Preview) и метаданные. Некоторые из метаданных сохраняются автоматически, например, идентификатор пользователя, который создал конкретный рабочий процесс, и дата его создания, но пользователи также могут предоставлять дополнительные метаданные, в том числе заполнить тег, идентифицирующий рабочий процесс, и ввести его текстовое описание.

Рис.23.3: Архитектура системы VisTrails

23.2. История проекта

Первые версии системы VisTrails были написаны на языках Java и C++. Версия на языке C++ была предложена нескольким самым первым пользователям, обратная связь с которыми сыграла важную роль в формировании наших требований к системе.

Наблюдая в ряде научных сообществ тенденцию увеличения количества библиотек и инструментальных средств, в которых используется язык Python, мы решили использовать этот язык в качестве основы системы VisTrails. Язык Python быстро становится универсальным современным средством, позволяющим состыковывать программное обеспечение, предназначенное для научных разработок. Во многих библиотеках, написанных на разных языках, таких как Fortran, C и C++, применяются привязки языка Python в качестве средства написания скриптов. Т.к. система VisTrails предназначается для объединения в рабочих процессах в единый оркестр большого количества различных библиотек, то делать это будет проще в случае, если система реализована на чистом языке Python. В частности, в языке Python есть возможности динамической загрузки кода, напоминающие те, которые присутствуют в среде языка LISP, у которой существенно большее сообщество разработчиков и исключительно богатая стандартная библиотека. В конце 2005 года мы приступили к разработке текущей системы, использующей Python/PyQt/Qt. Такой выбор значительно упростил создание в системе расширений, в частности, добавление новых модулей и пакетов.

Бета-версия системы VisTrails была впервые выпущена в январе 2007 года. С тех пор система была скачана более двадцати пяти тысяч раз.

23.3. Внутри системы VisTrails

На рис.23.3 показана схема архитектуры системы VisTrails, изображающая внутренние компоненты, в которых осуществляется поддержка функций пользовательского интерфейса, описанных вышеs. Выполнение рабочего процесса осуществляется под управлением движка Execution Engine, с помощью которого происходит отслеживание вызываемых операций и используемых с ними параметров и выполняется сохранение информации о ходе выполнения рабочего процесса (информация о происхождении данных в хоте выполнения процесса). Система VisTrails также позволяет во время выполнения процесса кэшировать промежуточные результаты в памяти и/или на диске. Как мы покажем в разделе 23.3, повторный запуск происходит только для новых комбинаций модулей и параметров, причем выполнение происходит с помощью обращений к функциям, лежащим глубже (например, к matplotlib). После этого результаты выполнения рабочего процесса вместе с исходными данными о происхождении данных могут добавляться в электронные документы (раздел 23.4).

Информация об изменениях в рабочих процессах накапливается в дереве версий Version Tree, которое может храниться в различных хранилищах данных, в том числе в хранилище файлов XML в локальном каталоге или в реляционной базе данных. В системе VisTrails также предоставляется механизм обработки запросов, который позволяет пользователям изучать информацию о происхождении данных.

Отметим, что, хотя система VisTrails была разработана как интерактивный инструмент, ее также можно использовать в режиме сервера. Как только рабочие процессы будут созданы, их можно будет выполнить на сервере VisTrails. Эта возможность полезна в ряде сценариев, в том числе при создании веб-интерфейса, в котором пользователям дается возможность взаимодействовать с рабочими процессами и запускать рабочие процессы в высокопроизводительных вычислительных средах.

23.3.1. Дерево версий: возможность выбора источника данных

Рис.23.4: Модель происхождения, основанная на изменениях

Новой концепцией, которая была введена с системой VisTrails, является понятие происхождения эволюции рабочего процесса. В отличие от предыдущих систем визуализации, использующих или созданных основе рабочих процессов, в которых понятие происхождения поддерживается только для полученных результатов обработки данных, в системе VisTrails рабочие процессы трактуются как элементы данных первого порядка и сохраняется информация об их происхождении. Наличие понятия эволюции происхождения рабочего процесса поддерживает использование рефлексивных рассуждений. Пользователи могут сразу изучать большое количество цепочек рассуждений, не теряя при этом результатов, а поскольку система сохраняет промежуточные результаты, пользователи могут использовать эту информацию при рассуждениях и делать из нее выводы. В результате также появляется возможность выполнять серии операций, благодаря которым процессы исследований упрощаются. Например, пользователи могут легко перемещаться по пространству рабочих процессов, созданных для данной задачи, визуально сравнивать рабочие процессы и их результаты (смотрите рис 23.4), а также исследовать (огромные) пространства параметров. Кроме того, пользователи могут запрашивать информацию о происхождении и обучаться на собственных примерах.

Информация об эволюции рабочих потоков собирается при помощи модели происхождения, базирующейся на изменениях. Как показано на рисунке 23.4, в системе VisTrails операции или изменения, которые применяются к рабочим процессам (например, добавление модуля, изменение параметров и т. д.), хранятся точно также, как транзакции в журнале транзакций баз данных. Эта информация сохраняется в виде дерева, в котором каждый узел соответствует версии рабочего потока, а ребро между родительским и дочерним узлом, представляет собой изменение, которое было применено к родительскому узлу для того, чтобы получить дочерний узел. При рассмотрении этого дерева мы пользуемся терминами «дерево версий» и vistrail (сокращение от visual trail — визуальный след) как взаимозаменяемыми. Обратите внимание, что модель, базирующаяся на изменениях, одинаковым образом сохраняет изменения значений параметров и изменения определений рабочего потока. Такой последовательности изменений достаточно, чтобы определить происхождение получаемых результатов данных, а также собрать информацию том, как с течением времени развивается рабочий процесс. Модель проста и компактна — для нее требуется существенно меньше места, чем альтернативному варианту хранения нескольких версий рабочих потоков.

Есть ряд преимуществ, которые можно получить при использовании такой модели. На рис.23.4 показаны функциональные возможности визуального отображения различий, которые в системе VisTrails предоставляются при сравнении двух рабочих процессов. Хотя процессы представлены в виде графов, использующих модель, основанную на изменениях, сравнение двух рабочих процессов становится очень простым: достаточно пройти по дереву версий и идентифицировать последовательность действий, которая потребовалась бы для того, чтобы преобразовать один рабочий процесс в другой.

Другим важным преимуществом модели происхождения, основанной на изменениях, является то, что используемое дерево версий может использоваться в качестве механизма поддержки совместной работы. Поскольку разработка рабочих процессов является крайне сложной задачей, она часто требует участие в работе сразу нескольких пользователей. С помощью дерева версий не только предоставляется интуитивно понятный способ визуализации вклада различных пользователей (например, с помощью окрашивания узлов в зависимости от того, какой пользователь создал соответствующий рабочий процесс), но и благодаря однородности модели можно использовать простые алгоритмы, позволяющие синхронизировать изменения, выполняемыми многими пользователями.

Во время выполнения рабочего процесса можно легко собрать и сохранить информацию о происхождении данных. После того, как выполнение будет завершено, также важно сохранить взаимосвязь между полученными данными и информацией о их происхождении, т.е. о том, какой рабочий процесс, какие параметры и какие исходные файлы использовались для получения результирующих данных. Если файлы данных или информация о происхождении данных перемещаются или изменяются, то становится трудно найти данные, связанные с информацией о происхождении, или найти информацию о происхождении, связанную с данными. В системе VisTrails предоставляется механизм долговременного хранения данных, с помощью которого осуществляется управление входными, промежуточными и выходными файлами данных, а также сохраняется взаимосвязь между информацией о происхождении данных и данными. Этот механизм обеспечивает лучшую поддержку при выполнении повторных вычислений, поскольку с его помощью гарантируется, что можно найти данные (и они будут правильными), указываемые в информации о происхождении. Другим важным преимуществом такого управления является то, что есть возможность кэшировать промежуточные данные, к которым затем также могут обращаться другие пользователи.

23.3.2. Выполнение рабочего процесса, кэширование данных

Движок в системе VisTrails, предназначенный для выполнения рабочего процесса, был разработан таким образом, чтобы можно было интегрировать в систему новые и уже существующие инструментальные средства и библиотеки. Мы постарались учесть различные стили, обычно используемые при добавлении научного программного обеспечения, создаваемого сторонними разработчиками и предназначенного для визуализации и вычислений. В частности, система VisTrails может быть интегрирована с прикладными библиотеками, которые существуют либо в виде предварительно скомпилированных двоичных файлов, выполняются в оболочке и в качестве входа и выхода используют файлы, либо в виде библиотек классов языков C++ / Java / Python, которым в качестве входа и выхода передаются внутренние объекты.

В системе VisTrails адаптирована модель исполнения потоков данных, в которой каждый модуль выполняет вычисления и данные, создаваемые модулем, перемещаются по соединениям, которые существуют между модулями. Модули выполняются по принципу снизу-вверх; каждый набор входных данных создается по требованию путем рекурсивного выполнения модулей, находящихся выше (мы говорим, что модуль A находится выше модуля B, если есть последовательность соединений, ведущая от А к В). Промежуточные данные временно хранятся либо в памяти (как объект языка Python) или на диске (объект-оболочка на языке Python, в котором содержится информация о доступе к данным).

Чтобы разрешить пользователям добавлять в систему VisTrails свои собственные функции, мы собрали расширяемую систему пакетов (смотрите раздел 23.3). Система пакетов позволяет пользователям включать в рабочий процесс их собственные или сторонние модули. Разработчик пакета должен определить набор вычислительных модулей и для каждого модуля задать входные и выходные порты, а также определить само вычисление. Для существующих библиотек нужно, чтобы в методе вычисления был указан перевод из входных портов в параметры существующих функций и отображение результирующих значений в выходные порты.

В поисковых задачах, подобные рабочие процессы, которые совместно используют общие подструктуры, часто выполняются в строгой последовательности. Для того, чтобы повысить эффективность выполнения рабочего процесса, промежуточные результаты в системе VisTrails кэшируются с целью свести к минимуму повторные вычисления,. Поскольку мы повторно пользуемся предыдущими результатами вычислений, мы неявно предполагаем, что будут использоваться модули кэширования: для них то, что было на входе, будет и на выходе. Это требование накладывает определенные ограничения на поведение классов, но мы считаем, что они обоснованы.

Но есть очевидные ситуации, в которых такого поведения достигнуть не удается. Например, модуль, который загружает файл на удаленный сервер или сохраняет файл на диске, обладает существенным побочным эффектом, тогда то, что он выдает в качестве выходных данных, имеет сравнительно небольшое значение. В других модулях может использоваться рандомизация, и их недетерминизм может быть желательным; такие модули можно пометить как некэшируемые. Однако, некоторые модули, которые, в сущности, не являются функциональными, могут быть преобразованы; функция, которая записывает данные в два файла, может быть представлена как выдающая содержимое этих файлов.

23.3. Внутри системы VisTrails

23.3.3. Сериализация и хранение данных

Одним из ключевых компонентов любой системы, поддерживающей использование информации о происхождении, является сериализация и хранение данных. В системе VisTrails данные изначально сохраняются в формате XML с использованием простых методов fromXML и toXML, встроенных во внутренние объекты системы (например, в дерево версий, в каждый модуль). Чтобы поддерживать эволюцию схемы подобных объектов, в этих функциях также закодированы все переходы между версиями схемы. По мере того, как проект разрабатывался, база наших пользователей росла, и мы решили поддержавать различные версии сериализации, в том числе и реляционные хранилища. В добавок по мере того, как добавлялись объекты схемы, нам для управления обычными данными потребовалось поддерживать более развитую инфраструктуру, позволяющую управлять версиями схем, выполнять преобразование между версиями и работать с сущностными отношениями. Чтобы все это сделать, мы добавили новый слой базы данных (db).

Слой (db) состоит из трех основных компонентов: доменных объектов, сервис-логики и методов сохранения данных. Доменная компонента и компонента сохранения данных позволяют использовать версии, так что для каждой версии схемы есть свой собственный набор классов. Таким образом, у нас поддерживается код, позволяющий читать каждую версию схемы. Также есть классы, в которых определены правила перевода объектов из одной версии схемы в другие. В классах сервис-логики предоставляются методы взаимосвязи с данными и методы, осуществляющие обнаружение и преобразование версий схемы.

Поскольку написание большей части этого кода утомительно и повторяющееся, мы используем шаблоны и мета-схемы, с помощью которых определяется как компоновка объекта (и все индексы доступа к памяти), так и код сериализации. Мета-схема написана на языке XML и является расширяемой в том смысле, что можно добавлять варианты сериализаций, отличные используемых по умолчанию XML и реляционных отображений, определенных в системе VisTrails. Этот подход похож на объектно-реляционные отображения и фреймверки, например, Hibernate [2] и SQLObject [3], но лишь добавлено несколько специальных процедур, которые автоматизируют такие задачи, как повторное отображение идентификаторов и преобразование объектов из одной версии схемы в следующую. Кроме того, мы также можем использовать те же самые мета-схемы при генерации кода сериализации для многих языков. Сначала была написана мета-схемаmeta-Python, в которой код домена и код сохранения данных были сгенерированы с помощью выполнения кода на языке Python с переменными, полученными из мета-схемы, а совсем недавно мы перешли к шаблонам Mako [4].

Автоматическое преобразование является ключевым для пользователей, которым нужно перенести свои данные на новые версии системы. В нашем проекте были добавлены специальные возможности, которые сделали для разработчиков такое преобразование чуть менее болезненным. Поскольку мы поддерживаем копии кода для каждой версии, перевод кода нужен просто для отображения одной версии в другую. На корневом уровне, мы определяем отображение, в котором указывается, каким образом любая версия может быть преобразована в любую другую версию. Для версии, которые сильно разнесены друг от друга, обычно указывается цепочка преобразований через несколько промежуточных версий. Сначала использовалось отображение только в прямом направлении, а это означает, что новые версии не могут преобразовываться в старые, но в совсем недавних отображениях схем было добавлено отображение в обратном направлении.

В каждом объекте есть метод update_version, который получает произвольную версию объекта и возвращает текущую версию. По умолчанию, он выполняет рекурсивное преобразование, при котором каждый объект обновляется при помощи отображения полей старого объекта в те, которые есть в новой версии. По умолчанию это отображение представляет собой копирование каждого поля с тем же самым именем, но с помощью метода, указываемого как "override" ("переопределяющий"), можно для любого поля можно переопределить поведение, определенное по умолчанию. Переопределяющий метод является методом, который берет старый объект и возвращает новую версию. Поскольку большинство изменений в схеме затрагивает только небольшое количество полей, в большинстве случаев достаточно отображения, выполняемого по умолчанию, но переопределения предоставляют гибкий механизм внесения локальных изменений.

23.3.4. Расширяемость с помощью пакетов и языка Python

В первом прототипе системы VisTrails был фиксированный набор модулей. Это была идеальная среда для разработки базовых идей, связанных с деревом версий VisTrails и кэширование многочисленных запусков рабочих процессов, но с точки зрения долгосрочной перспективы прототип был сильно ограничен.

Мы рассматриваем систему VisTrails как инфраструктуру для вычислений, а это, в буквальном смысле, значит, что в системе должны быть вспомогательные подмостки для других инструментальных средств и процессов, которые должны быть разработаны. Главное требование этого сценария является расширяемость. Типичный способ для достижения этой цели включает в себя определение целевого языка и написание соответствующего интерпретатора. Это выглядит привлекательным благодаря очень индивидуальному контролю, который предоставляется за исполнением. Эта привлекательность усиливается в свете наших требований кэширования. Тем не менее, внедрение полноценного языка программирования требует больших усилий, что никогда не было нашей главной целью. Что еще более важно, это заставит пользователей, которые просто пытаются использовать систему VisTrails, изучать совершенно новый язык, который им не нужен.

Нам нужна была система, которая позволяла пользователям легко добавлять свои собственные функции. В то же время, нам нужно, чтобы система была достаточно мощной, с тем чтобы самостоятельно реализовывать довольно сложные части программного обеспечения. В качестве примера, в системе VisTrails поддерживается библиотека визуализации VTK [5]. В VTK содержится около 1000 классов, которые изменяются в зависимости от вариантов компиляции, конфигурирования и операционной системы. Поскольку нам показалось, что будет контрпродуктивным и в конечном счете безнадежной писать разные пути кода для всех этих случаев, мы решили, что набор модулей VisTrails, который предоставляется в любом заданном пакете, нужно определять динамически и, естественно, библиотека VTK, стала наш целевой моделью для сложных пакетов.

Вычислительные науки были одной из областей, для использования в которых мы изначально нацеливали систему, и когда мы разрабатывали ее, среди этих ученых язык Python становился в качестве "кода для склейки". Поскольку при спецификации поведения модулей VisTrails, определяемых пользователями, используется сам язык Python, мы могли бы справиться со всем, но преодолели один большой барьер. Как оказалось, в языке Python предлагается замечательная инфраструктура для динамически определяемых классов и рефлексии. Почти каждое определение в языке Python имеет эквивалентную форму в виде выражения первого порядка. Для системы наших пакетов особенно важными возможностями рефлексии языка Python являются следующие две:

Конечно, использование языка Python в качестве целевого, имеет ряд недостатков. Прежде всего, такая динамическая природа языка Python означает, что пока мы не захотим реализовать некоторые вещи, такие как безопасность типов пакетов VisTrails, они вообще будут невозможны. Более того, некоторые из требований к модулям VisTrails, в частности такие, как прозрачность при ссылках на модули (подробнее об этом позже), не могут поддерживаться в Python. Тем не менее, мы считаем, что с помощью искусственных механизмов целесообразно ограничить конструкции, допустимые в языке Python, и с этой оговоркой, язык Python является чрезвычайно привлекательным языком, позволяющим расширять программное обеспечения.

23.3.5. Пакеты и сборки системы VisTrails

Пакет системы VisTrails инкапсулирует в себе набор модулей. Его самое обычное представление на диске точно такое, как представление пакета Python (к несчастью, допускающего коллизию имен). Пакет Python состоит из набора файлов Python, в которых определяются функции и классы языка Python. Пакет системы VisTrails является пакетом языка Python, в котором предпочтение отдается определенному интерфейсу. В нем есть файлы, в которых определены конкретные функции и переменные. В своей простейшей форме, пакет VisTrails должен быть каталогом, содержащим два файла: __init__.py и init.py.

Первый файл __init__.py добавлен в соответствие с требованиям к пакетам Python, в нем должны быть только несколько определений, являющиеся константами. Хотя нет никакого способа это проконтролировать, пакеты VisTrails, в которых не соблюдается это требование, рассматриваются как ошибочные. Среди значений, определенных в файле, есть уникальный глобальный идентификатор пакета, который используется для того, чтобы различать модули, когда происходит их сериализация или создаются их версии (версии пакета важны при обработке рабочего процесса и обновлении пакетов; смотрите раздел 23.4). В этом файле также могут быть функции package_dependencies и package_requirements. Поскольку в модулях VisTrails допускаются подклассы других модулей VisTrails, не входящих в корневой класс Module, вполне возможно, что в некотором пакете VisTrails есть расширение поведения другого пакета, и, поэтому, первый пакет должен быть проинициализирован перед вторым пакетом. Такие взаимозависимости пакетов определяются с помощью package_dependencies. С другой стороны, с помощью функции package_requirements определяются требования к библиотекам системного уровня, которые в системе VisTrails могут, в некоторых случаях, выполняться автоматически за счет использования абстракции сборок.

Сборка (bundle) является пакетом системного уровня, управление которым в системе VisTrails происходит при помощи специальных системных инструментальных средств, таких как RPM в RedHat или APT в Ubuntu. Когда все эти особенности соблюдены, система VisTrails может определить свойства пакета с помощью непосредственного импорта модуля Python и доступа к соответствующим переменным.

Во втором файле, init.py, находятся точки входа для всех актуальных определений модулей VisTrails. Наиболее важной особенностью этого файла является определение двух функций initialize и finalize. Функция initialize вызывается тогда, когда пакет становится доступным после то, как стали доступными все пакеты, от которых он зависит. С ее помощью выполняются задачи настройки для всех модулей в пакете. С другой стороны, функция finalize обычно используется, чтобы освободить ресурсы времени выполнения (например, могут быть стерты временные файлы, созданные в пакете).

Каждый модуль системы VisTrails представлен в пакете в виде одного класса Python. Чтобы зарегистрировать этот класс в системе VisTrails, разработчик пакетов один раз для каждого модуля VisTrails обращается к функции add_module. Такие модули VisTrails могут быть любыми классами языка Python, но в них должны соблюдаться некоторые требования. Первое из них то, что каждый из этих классов должен быть подклассом базового класса Python, определенным в системе VisTrails, хотя это может показаться скучным, обращением к Module. В модулях VisTrails может использоваться множественное наследование, но только один из классов должен быть модулем VisTrails — решетчатые иерархии в дереве модулей VisTrails не допускаются. Множественное наследование становится полезным, в частности, при определении смешанных классов: простое поведение, закодированное в родительских классах, может быть объединено вместе с тем, чтобы создать более сложное поведение.

Набор имеющихся портов определяет интерфейс модуля VisTrails, так что это влияет не только на отображение этих модули, но и взаимодействие этих модулей с другими. Итак, эти порты должны быть явно описаны в инфраструктуре системы VisTrails. Это может быть сделано либо путем соответствующих обращений к функциям add_input_port и add_output_port при обращении к функции initialize, либо указав списки add_input_port и add_output_port в каждом модуле VisTrails для каждого класса.

В каждом модуле вычисления, которые должны быть выполнены, определяются при помощи переопределения метода compute. Данные передаются между модулями через порты и доступ к ним осуществляется через методы get_input_from_port и set_result. В традиционной среде потоков данных, порядок выполнения определяется по требованию при помощи запросов данных. В нашем случае, порядок выполнения определяется при помощи топологической сортировки модулей рабочего процесса. Поскольку для алгоритма кэширования требуется ациклический граф, мы составляем план выполнения в обратном топологическом порядке сортировки, с тем чтобы обращения к этим функциям не требовали переключения на исполнение модулей, находящихся выше. Мы приняли это решение сознательно: оно позволяет проще рассматривать поведение каждого модуля вне зависимости от всех остальных модулей, что делает нашу стратегию кэширования более простой и надежной.

В качестве общей рекомендации, в модулях системы VisTrails нужно при переопределении модуля compute воздерживаться от использования функций с побочными эффектами. Как отмечалось в разделе 23.3, благодаря этому требованию становится возможным кэширование конкретного рабочего процесс: если модуль отвечает этому требованию, то его поведение является функцией, зависящей от выходных данных модулей, находящихся выше. Тогда для каждого ациклического подграфа вычисления можно делать только один раз, и результатами можно пользоваться повторно.

23.3.6. Передача данных в виде модулей

Одной особенностью модулей VisTrails и их взаимодействия является то, что данные, которые передаются между модулями VisTrails, сами являются модулями системы VisTrails. В системе VisTrails есть единственная иерархии классов модулей и данных. Например, модуль может представлять собой результат вычислений (и, по сути, в каждом модуле есть определяемый по умолчанию выходной порт "self"). Основным недостатком является исчезновение концептуального различия между вычислениями и данными, которые иногда учитываются в системах с архитектурой на базе потоков данных. Но, в этом есть два больших преимущества. Во-первых, точно имитирует система типов объектов языков Java и C++, и этот выбор был не случайным: для нас важно поддерживать автоматическое подключение больших библиотек классов, таких как VTK. В этих библиотеках допускаются объекты, которые в качестве результата вычислений создают другие объекты, что усложняет такую поддержку в случаях, когда есть различия между вычислениями и данными.

Вторым преимуществом этого решения является то, что становится проще определять значения констант и параметров, устанавливаемых пользователями, и их интеграция с остальной частью системы становится более единообразной. Рассмотрим, например, рабочий процесс, который загружает в сеть локально расположенный файл, заданный как константа. В настоящее время для этого используется графический пользовательский интерфейс, в котором URL может быть указан в качестве параметра (смотрите область редактирования параметров Parameter Edits на рис 23.1). Естественное изменение этого рабочего процесса состоит в том, чтобы можно было определить URL, который уже был вычислен выше. Нам бы хотелось, чтобы остальную часть рабочего процесса требовалось менять как можно меньше. Если предположить, что модули могут выдавать в качестве результата самих себя, то мы бы могли просто подключить строку с правильным значением к порту соответствующего параметра. Поскольку при выдаче константы, результатом ее вычисления является сама эта константа, то поведение было бы точно такое, как если бы значение было фактически определено как константа.

Рис.23.5: Прототипирование новой функциональности с помощью модуля PythonSource

Есть и другие соображения, касающиеся использования констант. Для каждого константного типа есть свой собственный идеальный графический интерфейс для задания значения. Например, в системе VisTrails в модуле констант — файлов предлагается диалог, позволяющий выбрать файл; значение типа Boolean определяется с помощью отметки, делаемой в чекбоксе; значение цвета выбирается с помощью диалога средства выбора цвета, своего собственного для каждой операционной системы. Чтобы добиться такого обобщения, разработчик должен для конкретной константы создать подкласс базового класса Constant и переопределить методы, в которых определяется виджет графического интерфейса и строковое представление (с тем, чтобы любые константы можно было сериализовать на диске).

Отметим, что для простых задач прототипирования, в системе VisTrails имеется встроенный модуль PythonSource. Модуль PythonSource можно использовать для непосредственной вставки скриптов в рабочий процесс. В конфигурационном окне модуля PythonSource (смотрите рис.23.5) предоставляется возможность указать несколько входных и выходных портов, а также указать код на языке Python, который должен быть выполнен.

23.4. Компоненты и возможности

Как уже говорилось выше, в системе VisTrails предоставляется набор функций и пользовательских интерфейсов, упрощающих создание и выполнение исследовательских задач, в которых требуются вычисления. Ниже мы опишем некоторые из них. Мы также кратко обсудим, как система VisTrails используется в качестве инфраструктуры, поддерживающей создание публикаций с большим объемом информации о происхождении данных. Более полное описание системы VisTrails и ее возможностей смотрите в документации, имеющейся в сети [6].

Рис.23.6: Таблица визуализации

23.4.1. Таблица визуализации

Система VisTrails позволяет пользователям с помощью таблицы визуализации исследовать и сравнить результаты, полученные в нескольких рабочих процессах (смотрите рис 23.6). Эта таблица (электронная таблица — прим.пер.) представляет собой пакет VisTrails со своим собственным интерфейсом, состоящий из листов и ячеек. Каждый лист содержит набор ячеек и обладает настраиваемым интерфейсом. Ячейка содержит визуальное представление результатов, созданных рабочим процессом, и ее можно настроить для отображения различных типов данных.

Чтобы отобразить ячейку таблицы, в рабочем процессе должен быть модуль, являющийся производным от базового модуля SpreadsheetCell. Каждый модуль SpreadsheetCell соответствует отдельной ячейке в таблице, так что в одном рабочем процессе можно создавать несколько ячеек. Метод compute модуля SpreadsheetCell осуществляет обработку взаимодействия между движком Execution Engine (рис. 23.3) и электронной таблицей. Во время выполнения таблица создает ячейку в соответствие с запрошенным ею типом при помощи динамического создания экземпляра класса Python. Таким образом, конкретные визуальные представления можно получить с помощью создания подкласса SpreadsheetCell, имеющего метод compute, который отправит в таблицу сообщение о конкретном типе ячейки. Например, в рабочем процессе на рис.23.1, MplFigureCell является модулем SpreadsheetCell, созданным для показа изображений, созданных с помощью matplotlib.

Поскольку в таблице в качестве основы графического пользовательского интерфейса применяется PyQt, виджеты конкретных ячеек должны быть подклассами класса QWidget из PyQt. В них также должен быть определен метод updateContents, который, когда поступают новые данные, вызывается таблицей для обновления виджета. В виджете каждой ячейки можно с помощью реализации метода toolbar дополнительно определить специальную инструментальную панель; когда ячейка выбирается, эта панель будет отображаться на месте инструментальной панели таблицы.

На рис 23.6 показана таблица для случая, когда выбрана ячейка VTK; в этом случае в инструментальной панели представлены конкретные виджеты, предназначенные для экспорта изображений PDF, возвращать обратно в рабочий процесс информацию о положении камеры и создавать анимации. В пакете электронной таблицы определен настраиваемый виджет QCellWidget, в которым предлагаются обычные функции, такие как воспроизведение истории (анимация) и отработка мульти-сенсорных событий. Его можно использовать вместо виджета QWidget для более быстрой разработки ячеек новых типов.

Даже хотя в таблице в качестве типов используются виджеты PyQt, можно интегрировать виджеты, написанные с помощью других инструментальных средств, предназначенных для создания графических пользовательских интерфейсов. Чтобы это сделать, виджет должен экспортировать свои элементы на нативную платформу, а затем можно воспользоваться PyQt с тем, чтобы их получить. Мы используем такой подход для виджета VTKCell, т. к. виджен фактически написан на языке C++. Во время выполнения, виджет VTKCell получает идентификатор обработчика окна Win32, X11 или Cocoa/Carbon в зависимости от используемой системы, и отображает его в канвас таблицы.

Листы могут настраиваться точно также, как и ячейки. По умолчанию, каждый лист имеет табличную компоновку и помещается в окно с закладками. Однако, любой лист можно отсоединить от окна электронной таблицы, чтобы можно было видеть одновременно несколько листов. Также можно с помощью подкласса класса StandardWidgetSheet, который является виджетом PyQt, создать другую компоновку листа. Подкласс StandardWidgetSheet управляет расположением ячеек, а также взаимодействует с ними в режиме редактирования. В режиме редактирования, пользователи могут манипулировать с расположением ячеек и выполнять дополнительные действия с самими ячейками, а не только с их содержимым. К числу таких действий относится применение аналогий (смотрите раздел 23.4) и создание новых версий рабочих процессов, работающих с исследуемыми параметрами.

23.4.2. Визуальные различия и аналогии

Когда мы разработали систему VisTrails, мы, в добавок, к возможности сбора данных, хотели использовать информацию о происхождении данных. Во-первых, мы хотели, чтобы пользователи видели точные различия между версиями, но потом мы поняли, что более полезной функцией была бы возможность определять различия в других рабочих процессах. Решение обеих этих задач, возможно, поскольку в системе VisTrails отслеживается эволюция рабочих процессов.

Поскольку в дереве версий собираются все изменения и мы можем инвертировать каждое действие, мы можем найти полную последовательность действий, с помощью которых одна версия преобразуется в другую. Обратите внимание, что некоторые изменения компенсируют друг друга, что делает возможным сократить эту последовательность. Например, нет необходимости при определении различий учитывать добавление модуля, который был позже удален. Наконец, у нас есть некоторые эвристики для дальнейшего упрощения последовательности: когда один и тот же модуль появился в обоих рабочих процессах, но был добавлен с помощью отдельных действий, мы, мы отменить его добавление и удаление.

Анализируя набор изменений, мы можем создать визуальное представление, в котором показаны похожие и различные модули, соединения и параметры. Это проиллюстрировано на рис.23.4. Модули и соединения, которые появляются в обоих рабочих процессах, показаны серым цветом, а те, которые появляются только в одном рабочем процессе, окрашены в тот цвет, который соответствует тому рабочему процессу, в котором они находятся. Совпадающие модули с различными параметрами оттеняются светло серым и пользователь может для каждого модуля проверить различия параметров по таблице, в которой показаны значения для каждого рабочего процесса.

Операция аналогии позволяет пользователям определить эти различия и применить их к другим рабочим процессам. Если пользователь внес ряд изменений в существующий рабочий процесс (например, изменяет разрешение и формат файла для выдаваемого изображения), он может с помощью аналогии применить те же самые изменения к другим рабочим процессам. Для этого пользователь выбирает исходный и целевой рабочие процессы, по которым определяется набор необходимых изменений, а также рабочий процесс, к которому по аналогии должны быть применены эти различия. Система VisTrails вычисляет различие между первыми двумя рабочими процессами, взятыми в качестве шаблона, а затем определяет, как нужно переопределить это различие с тем, чтобы применить его к третьему рабочему процессу. Поскольку различия можно применять к рабочим процессам, которые не совпадают с начальным рабочим процессом, нам нужно нестрогое соответствие (soft matching), которое позволяет считать соответствующими аналогичные модули. С помощью такого соответствия, мы можем переопределить различие таким образом, чтобы к выбранному рабочему процессу можно было применить последовательность изменений [SVK +07]. Метод не является абсолютно надежным, и могут создаваться такие новые рабочие процессы, которые будут не совсем такими, как нам хотелось. В таких случаях, пользователь может попытаться исправить любые возникшие ошибки, либо может вернуться к предыдущей версии и применить изменения вручную.

Чтобы вычислить нестрогое соответствие, используемое в аналогиях, мы потребуется соблюсти баланс между локальными соответствиями (одинаковых или очень похожих модулей) с общей структурой рабочего процесса. Отметим, что вычисление даже идентичного соответствия неэффективно из-за строгости изоморфизма подграфов, поэтому мы нам требуется использовать эвристику. Короче говоря, если в двух рабочих процессах два в некотором смысле похожих модуля находятся среди сходных соседей, мы можем сделать вывод, что эти два модуля функционируют аналогичным образом и должны также расцениваться как соответствующие друг другу. Более формально, мы строим продукционный граф, в котором каждый узел является возможным сопряжением модулей в исходных рабочих процессах, а ребра обозначают общие соединения. Затем, мы запускаем шаги рассеяния оценок в каждом узле по ребрам, идущим к соседних узлам. Это марковский процесс, аналогичный ранжированию страниц, используемому Google, который, в конце концов, сойдется, и, в сущности, останется набор оценок, в которых теперь будет некоторая глобальная информация. Исходя из этих оценок мы можем с помощью порога, определяющие очень разнородные модули как непарные, определить лучшее соответствие.

23.4. Компоненты и возможности

23.4.3. Запрос информации о происхождении

Информация о происхождении данных, собираемая системой VisTrails, включает в себя набор, состоящий из процессов, каждый со своей собственной структурой, метаданных и журнала выполнения. Важно то, что пользователи могут получить доступ к этим данным и могут их изучить. В системе VisTrails есть как текстовый, так и визуальный (WYSIWYG) интерфейсы запросов. Для такой информации, как теги, аннотации и даты, пользователь может использовать поиск по ключевым словам с возможностью разметки. Например, найти все рабочие процессы с помощью ключевого слова plot, которые были созданы пользователем user:~dakoop. При этом запросы о конкретных подграфав рабочего процесс проще представить с помощью визуального интерфейса запросов по образцу, в котором пользователи могут либо создать запрос с нуля, либо скопировать и модифицировать часть уже существующего конвейера.

При разработке такого интерфейса запросов по образцу, мы взяли из существующего редактора рабочих процессов Workflow Editor большую часть кода без изменений, добавив лишь небольшие изменения, параметризирующие конструкцию. Что касается параметров, то чаще полезнее искать диапазоны значений и ключевые слова, а не точные значения. Поэтому, в поля значений параметров мы добавили модификаторы; когда пользователи добавляют или изменяет значение параметра, они могут выбрать один из этих модификаторов, которые по умолчанию, задают точное совпадение. Кроме того, что можно сделать визуальный запрос, результат запроса также можно получить в визуальном виде. Совпадающие версии подсвечиваются в дереве версий и любой выбранный рабочий процесс отображается с выделением соответствующей части. Пользователь может выйти из режима результатов запроса инициировав другой запрос или щелкнув по кнопке сброса.

23.4.4. Хранение данных

Система VisTrails хранит информацию о том, как были получены результаты, и с помощью каких шагов. Однако, заново воспроизвести рабочий процесс может быть трудно в случае, если данных, необходимых для рабочего процесса, больше нет. Кроме того, для длительных процессов для того, чтобы избежать повторных перевычислений, может быть полезным между сессиями сохранять промежуточные данные в кэше.

В многих системах, использующих рабочие процессы, в качестве информации о происхождении данных запоминаются пути к файлам в файловой системе, но при таком подходе возможны проблемы. Пользователь может переименовать файл, переместить рабочий процесс в другую систему и не скопировать данные или изменить содержимое данных. В любом из этих случаев, недостаточно в качестве информации о происхождении данных запомнить путь к файлу. Хеширование данных и сохранение хэш значения в информации о происхождении помогут определить, были ли изменены данные, но не помогут найти данные, если они есть. Чтобы решить эту проблему, мы создали пакет Persistence Package - пакет системы VisTrails, использующий инфраструктуру управления версиями для хранения данных, к которым можно обращаться как к информации о происхождении. В настоящее время мы для управления данными используем систему Git, хотя также просто можно пользоваться другими системами.

Мы, чтобы идентифицировать данные, используем универсальные уникальные идентификаторы (UUID) и хэш-значения, получаемые в Git при подтверждении сохранения версий. Если между двумя вычислениями данные изменились, то в репозитарий будет помещена новая версия данных. Таким образом, в любом случае данные можно найти с помощью составного идентификатора - кортежа (uuid, version). Кроме того, мы сохраняем хеш-значение и сигнатуру той части рабочего процесса, которая находится выше и с помощью которой были созданы эти данные (если они не являются входными). Это позволяет обращаться к данным, которые могут идентифицироваться по-разному, а также повторно использовать данные, когда снова запускается то же самое вычисление.

Главным, с чем мы столкнулись при разработке этого пакета было то, как пользователи смогут находить и повторно использовать свои данные. Также нам хотелось, чтобы данные хранились в одном и том же репозитарии независимо от того, используются ли они как входные, выходные и промежуточные (выходные данные одного рабочего процесса могут быть входными данными другого рабочего процесса). Есть два основных способа, с помощью которых пользователь может идентифицировать данные: создать новую ссылку на данные или использовать существующую. Обратите внимание, что после первого вычисления новая ссылка станет существующей, поскольку во время исполнения она будет сохранена; позже пользователь, если захочет, может решить создать другую ссылку, но это редкий случай. Поскольку пользователи часто хотят всегда пользоваться последней версией данных, ссылки, задаваемая без указания конкретной версии, будут, по умолчанию, указывать последнюю версию.

Вспомним, что перед выполнением модуля, мы рекурсивно обновляем все его входные данные. Модуль с хранящимися данными не будет обновлять свои входные данные в случае, если вычисления, находящиеся выше, уже отработали. Чтобы это определить, мы берем в репозитарии сигнатуру той части рабочего процесс, которая находится выше, и, если такая сигнатура существует, ищем в репозитарии ранее вычисленные данные. Кроме того, мы записываем идентификаторы и версии данных в виде информации об их происхождении, с тем, чтобы можно было повторно воспроизвести конкретное вычисление.

23.4.5. Обновления

Поскольку информация о происхождении данных является основой системы VisTrails, ключевым вопросом становится возможность обновлять старые рабочие процессы так, чтобы они могли выполняться с новыми версиями пакетов. Поскольку пакеты могут создаваться сторонними разработчиками, нам необходима как инфраструктура, позволяющая обновлять рабочие процессы, так и специальные средства с тем, чтобы разработчики пакетов могли указывать обновления путей к данным. Основным действием, выполняемым при обновлении рабочего процесса, является замена некоторого модуля его новой версией. Обратите внимание, что это действие осложняется тем, что мы должны изменить все подключения и все параметры старого модуля. Кроме того, например, при изменении интерфейса модуля, обновления могут потребовать переконфигурировать, переназначить или переименовать эти параметры или подключения такого модуля.

Каждый пакет (вместе со связанными с ним модулями) помечается номером версии, и когда эта версия изменяется, мы допускаем, что в этом пакете могут быть изменены также и модули. Обратите внимание, что некоторые из модулей или даже большая их часть могут остаться прежними, но без нашего собственного анализа кода мы это проверить не сможем. Но мы пытается автоматически обновить любой модуль, интерфейс которого не изменился. Чтобы сделать это, мы стараемся заменить модуль новой версией и выдать исключительное состояние в случае, если он не работает. Если разработчики изменяют интерфейс модуля или переименовывают модуль, мы даем им возможность указывать эти изменения явно. Чтобы сделать все это более контролируемым, мы создали метод remap_module, который позволяет разработчикам указывать только те места, где обновления, выполняемые по умолчанию, должны быть изменены. Например, разработчик, который переименовал входной порт "file" в "value", можен указать это конкретное переназначение с тем, чтобы, когда будет создан новый модуль, любое подключение к порту "file" в старом модуле было бы теперь подключением к порту "value". Ниже показан пример обновления для встроенного модуля VisTrails:

def handle_module_upgrade_request(controller, module_id, pipeline):
   module_remap = {'GetItemsFromDirectory':
                       [(None, '1.6', 'Directory',
                         {'dst_port_remap':
                              {'dir': 'value'},
                          'src_port_remap':
                              {'itemlist': 'itemList'},
                          })],
                   }
  return UpgradeWorkflowHandler.remap_module(controller, module_id, pipeline,
                                             module_remap)

Этот фрагмент кода обновлений рабочих процессов, в которых старый модуль GetItemsFromDirectory (любой версии до 1.6) заменяется на модуль Directory. Здесь порт dir старого модуля отображается в порт value, а порт itemlist в порт itemList.

Любое обновление создает в дереве версий новую версию с тем, чтобы можно было отличать друг от друга и сравнивать исполнения рабочих процессов перед и после обновления. Вполне возможно, что обновление изменит выполнение рабочего процесса (например, если разработчик пакета исправит ошибку), и нам нужно отследить эту информацию о происхождении данных. Отметим, что в старых версиях VisTrails, для этого могло требоваться обновлять каждую версию в дереве. Чтобы избежать путаницы, мы обновляем только те версии, к которым пользователь может переходить. Кроме того, мы предоставляем механизм предпочтения, который позволяет пользователю отложить запоминание любого обновления до тех пор, пока рабочий процесс не будет модифицирован или выполнен; если пользователь просто просматривает такую версию, нет необходимости сохранять обновление.

23.4.6. Совместный доступ и публикация результатов, использующих информации о происхождении

Несмотря на то, что воспроизводимость результатов является краеугольным камнем научного метода, в текущих публикациях, описывающих вычислительные эксперименты, часто нет достаточного объема данных, позволяющих повторять или обобщать представленные результаты. В последнее время возобновляется интерес к публикациям с воспроизводимыми результатами. Основное препятствие к более широкому распространению такой практики в том, что трудно создать сборку данных, в которую бы входили все компоненты (например, данные, код, параметры настроек), необходимые при воспроизведении и проверке результата.

Благодаря сбору подробной информации о происхождении данных, а также многим возможностям, описанным выше, система VisTrails делает более простым этот процесс для вычислительных экспериментов, проводимых в рамках системы. Тем не менее, нужны средства как для ссылок на документы, так и для совместного доступа к информации о происхождении данных.

У нас есть разработаные пакеты VisTrails, которые позволяют представлять в статьях результаты вместе с информацией о происхождении данных. С помощью пакета LaTeX, который мы разработали, пользователи могут добавлять рисунки, относящиеся к рабочим процессам VisTrails. С помощью следующего кода на LaTeX будет создаваться рисунок с результатами выполнения рабочего процесса

\begin{figure}[t]
{
\vistrail[wfid=119,buildalways=false]{width=0.9\linewidth}
}
\caption{Visualizing a binary star system simulation. This is an image
  that was generated by embedding a workflow directly in the text.}
\label{fig:astrophysics}
\end{figure}

Если документ собран с использованием pdflatex, то команда \vistrail вызовет скрипт Python с параметрами, полученными в ответ на сообщение XML-RPC, посланное на сервер VisTrails с тем, чтобы выполнить рабочий процесс с идентификатором id 119. Этот же скрипт Python с помощью генерации команд LaTeX с гиперссылкой \includegraphics, в которых указаны параметры компоновки документа (width=0.9\linewidth), выполнит загрузку результатов рабочего процесса с сервера и добавит их в результирующий документ PDF.

Кроме того, результаты, полученные в системе VisTrails, можно включать в веб-страницы, страницы вики, документы Word и презентации PowerPoint. Связь между Microsoft PowerPoint и системой VisTrails осуществляется через компонентную объектную модель Component Object Model (COM) и интерфейс связывания и внедрения объектов Object Linking and Embedding (OLE). Чтобы объект взаимодействовал с PowerPoint, в нем должны быть реализованы, по меньшей мере, интерфейсы IOleObject, IDataObject и IPersistStorage модели COM. Поскольку для сборки нашего объекта OLE мы используем класс QAxAggregated из Qt, который является абстракцией реализации интерфейсов COM, то инетерфейсы IDataObject и IPersistStorage будут с помощью Qt созданы автоматически. Таким образом, мы должны реализовать только интерфейс IOleObject. При этом самый важным будет обращение к DoVerb. Оно позволит системе VisTrails реагировать на определенные действия PowerPoint, например, на активацию объектов. В нашей реализации, когда объект VisTrails активируется, мы загружаем приложение VisTrails и позволяем пользователям открывать и взаимодействовать с конвейером, который они хотят вставить. После того, как они закроют систему VisTrails, результат работы конвейера будет показан в PowerPoint. Вместе с объектом OLE также хранится информация и о конвейере.

Чтобы позволить пользователям свободно обмениваться своими результатами вместе с сопутствующей информацией о происхождении данных, мы создали сайт crowdLabs. Сайт crowdLabs [7] является социальным веб-сайтом, на котором набор полезных инструментальных средств объединен с масштабируемой инфраструктурой с тем, чтобы можно было предоставить ученым среду для совместного анализа и визуализации данных. Сайт crowdLabs тесно интегрирован с системой VisTrails. Если пользователь хочет предоставить для общего пользования какие-нибудь результаты, полученные в системе VisTrails, он может непосредственно из системы VisTrails подключиться к серверу crowdLabs для загрузки этой информации. После того, как информация будет загружена, пользователи могут обращаться к рабочим процессам и выполнять их через веб-браузер — эти рабочие процессы выполняются сервером VisTrails, который поддерживает работу crowdLabs. Подробности о том, как система VisTrails используется для создания публикаций с воспроизводимыми данными, смотрите по ссылке http://www.vistrails.org.

23.5. Усвоенные уроки

К счастью, еще в 2004 году, когда мы начали размышлять о системе исследования и визуализации данных, в которой должна поддерживаться работа с информацией о происхождении данных, мы еще не представляли, насколько будет сложно и сколько времени потребуется для того, чтобы добраться до той точки, в которой мы сейчас находимся. Если бы мы это представляли, то мы, вероятно, никогда бы не начали проект.

На начальном этапе единственной стратегией, которая работала хорошо, было быстрое прототипирование новых функций и передача их выбранной группе пользователей. Первые отзывы и одобрения, которые мы получили от этих пользователей, сыграли важную роль в продвижении проекта вперед. Без обратной связи с пользователями систему VisTrails было бы невозможно разрабатывать. Если и есть единственный аспект проекта, который нам бы хотелось выделить, это то, что большинство функций в системе были разработаны в качестве прямой реакции на обратную связь с пользователями. Тем не менее, стоит отметить, что то, что во многих ситуациях просили пользователи, было не самым лучшим, в чем они нуждались. Снова и снова мы были вынуждены проектировать и перепроектировать функции с тем, чтобы быть уверенными, что они будут полезными и будут должным образом интегрированы в систему.

Если принять во внимание наш подход, ориентированный на пользователей, то можно было бы ожидать, что каждая возможность, которую мы разработали, будет активно использоваться. К сожалению, этого не произошло. Иногда причиной этого было то, что функция была весьма "необычной", поскольку ее нельзя было найти в других инструментальных средствах. Например, аналогии и даже дерево версий не были теми понятиями, с которыми знакомо большинство пользователей и из-за этого им требовалось некоторое время с тем, чтобы с ними освоиться. Другим важным вопросом была документация, вернее отсутствие таковой. Как и во многих других проектах с открытым кодом, мы были намного больше преуспели в разработке новых функций, чем в документировании существующих. Такое отставание в документации ведет не только к неполному использованию полезных функций, но и к многочисленным вопросам в наших списках рассылок.

Одна из проблем изучения такой системы, как VisTrails , является то, что она весьма общая. Несмотря на все наши усилия по улучшению удобства использования, система VisTrails является сложным инструментом и некоторым пользователям требуются значительные усилия на ее изучение. Мы считаем, что с течением времени, с улучшенной документацией, с дальнейшими улучшениями в системе, и с большим количеством примеров ее использования в конкретных областях, усилия по ее освоению станут существенно меньшими. Также, когда концепция информации о происхождении данных станет более распространенной, пользователям будет проще понять философию, которой мы следовали при разработке системы VisTrails.

23.5.1. Благодарности

Мы хотели бы поблагодарить всех талантливых разработчиков, которые внесли свой вклад в систему VisTrails: Erik Anderson, Louis Bavoil, Clifton Brooks, Jason Callahan, Steve Callahan, Lorena Carlo, Lauro Lins, Tommy Ellkvist, Phillip Mates, Daniel Rees и Nathan Smith. Отдельное спасибо Antonio Baptista, который сыграл важную роль, помогая нам развивать концепцию проекта; и Matthias Troyer, чье сотрудничество помогло нам улучшить систему, и, в частности, стало импульсом к разработке и реализации функций, предназначенных для публикации результатов исследований с большим объемом информации о происхождении данных. Исследование и разработка системы VisTrails финансируется Национальным научным фондом по грантам IIS 1050422, IIS-0905385, 0844572 IIS, ATM-0835821, IIS-0844546, IIS-0746500, CNS-0751152, IIS-0713637, OCE-0424602, IIS-0534628, CNS-0514485, IIS-0513692, CNS-0524096, CCF-0401498, OISE-0405402, CCF-0528201, CNS-0551724, а также грантам Министерствам энергетики SciDAC (центры VACET и SDM) и факультета IBM.

Примечания

  1. http://www.vistrails.org
  2. http://www.hibernate.org
  3. http://www.sqlobject.org
  4. http://www.makotemplates.org
  5. http://www.vtk.org
  6. http://www.vistrails.org/usersguide
  7. http://www.crowdlabs.org

24.1. Что такое VTK?

Система VTK была первоначально задумана как система визуализации научных данных. Многие их тех, кто не связан с вопросами визуализации, наивно считают визуализацию некоторым видом геометрического рендеринга: изучение виртуальных объектов и взаимодействия с ними. Хотя это действительно часть визуализации, в общем визуализация данных включает в себя весь процесс преобразования данных в сенсорное представление, обычно - в изображения, но также включает в себя тактильные, слуховые и другие виды представлений. Формируемые данные содержат не только геометрические и топологические конструкции, в том числе и такие абстракции, как разложение на сеточные или другие сложные пространственные элементы, но атрибуты основной структуры, например, скалярные значения (например, температуру или давление), векторы (например, скорость), тензоры (например, напряжений и деформаций), а также такие атрибуты рендеринга, как нормали к поверхности и текстурные координаты.

Следует отметить, что данные, представляющие пространственно-временную информацию рассматривается, как правило, как часть научной визуализации. Однако есть более абстрактные формы данных, такие как демографические данные по рынкам, веб-страницы, документы и другая информация, которая может быть представлена только через абстрактные (т.е. не пространственные-временные) отношения, такие как неструктурированные документы, таблицы, графики и деревья. Эти абстрактные данные, как правило, обрабатываются с помощью методов, относящихся к визуализации информации. Благодаря сообществу, система VTK теперь способна обрабатывать научные данные и осуществлять визуализацию информации.

В качестве системы визуализации, роль VTK состоит в получении данных в этой форме и превращении их, в конечном итоге, в формы, которые воспринимаемые сенсорным аппаратом человека. При этом одним из основных требований системы VTK является ее способность создавать конвейеры с потоками данных, через которые можно пропустить, обработать, представить и в конечном счете визуально отобразить данные. Поэтому этот инструментальный набор должен был с архитектурной точки зрения представлять собой гибкую систему и это было отражено на многих уровнях проекта. Например, мы разработали систему специального назначения VTK в виде инструментального средства со многими сменными компонентами, которые можно объединять для обработки широкого спектра данных.

24.2. Архитектурные особенности

Перед тем, как погружаться в конкретных особенности архитектуры VTK, давайте взглянем на высокоуровневые концепции, которые существенно повлияли на разработку и использование системы. Одной их таких особенностей является возможность в VTK использовать гибридные обвертки. С помощью этой возможности в реализации С++ в VTK автоматически создаются привязки к языкам Python, Java и Tcl (можно добавить и были добавлены дополнительные языки). Наиболее опытные разработчики будут работать в языке C++. Пользователи и разработчики приложений могут использовать язык C++, но часто для них предпочтительнее интерпретируемые языки, упомянутые выше. Этот гибрид среды компиляции/интерпретации сочетает в себе лучшее из двух миров: высокую производительность ресурсоемких алгоритмов и гибкость при прототипировании или разработке приложений. На самом деле в сообществе научных вычислений многим нравится такой подход к многоязычным вычислениям и они часто пользуются системой VTK в качестве шаблона для разработки своего собственного программного обеспечения.

Если рассматривать процесс разработки программного обеспечения, то в системе VTK используется CMake для управления сборкой, CDash/CTest - для тестирования и CPack - для кросс-платформенного развертывания пакетов. Действительно, система VTK может быть откомпилирована практически на любом компьютере, в том числе и на суперкомпьютерах, которые зачастую обладают заведомо скромными средами разработки. Кроме того, есть средства генерации веб-страниц, вики, списков рассылок (для пользователей и для разработчиков) и документации (то есть, Doxygen), а инструментальные средства дополнены треккером ошибок (Mantis).

24.21. Основные особенности

Поскольку VTK является объектно-ориентированной системой, в VTK тщательно контролируется доступ к элементам класса и экземплярам данных . Обычно все элементы данных являются либо защищенными (т. е. protected), либо приватными (т. е. private). Доступ к ним осуществляется через методы Set и Get с особыми вариациями, относящимися к логическим данным, модальным данным, строкам и векторам. Многие из этих методов на самом деле создается с помощью макросов, вставленных в заголовочные файлы класса. Так, например:

vtkSetMacro(Tolerance,double);
vtkGetMacro(Tolerance,double);

после раскрытия будут выглядеть следующим образом:

virtual void SetTolerance(double);
virtual double GetTolerance();

Есть много причин, выходящих за рамки простого улучшения ясности кода, для использования этих макросов. В VTK существуют важные элементы данных, управляющие процессом отладки, обновляющие значения времени изменения объектов (MTime), а также надлежащим образом управляющие подсчетом ссылок на объекты. Эти макросы обрабатывают такие данные правильным образом и использование этих макросов настоятельно рекомендуется. Например, особенно пагубная ошибка в системе VTK происходит тогда, когда с помощью Mtime не удается правильно управлять объектами. В этом случае код может выполняться не тогда, когда это надо, или может выполнять слишком часто.

Одной из сильных сторон системы VTK является ее сравнительно простые средства представления и управления данными. Обычно для представления подряд идущих частей информации используются различные массивы данных определенного типа (например, vtkFloatArray). Например, список из трех точек XYZ может быть представлен в vtkFloatArray девятью записями (x,y,z, x,y,z и т. д.). Для таких массивов существует понятие кортежа, поэтому 3D-точка представляет собой кортеж из трех элементов, тогда как симметричная тензорная матрица размером 3×3 представляет собой кортеж, состоящий из 6 элементов (в некоторых из которых можно, благодаря наличию в них симметрии, сэкономить место). Такая архитектура была взята за основу специально, поскольку в научных вычислениях она обычно используется в качестве интерфейса с системами, обрабатывающими массивы (например, Fortran), и с ее помощью гораздо эффективнее выделять и освобождать память в больших фрагментах данных, следующих непрерывно друг за другом. Кроме того, взаимодействие с данными, сериализация и выполнение операций ввода-вывода, как правило, происходит гораздо эффективнее, когда данные непрерывно следуют друг за другом. В системе VTK с помощью этих основных массивов данных (различных типов) происходит представление многих данных и для них есть много разнообразных удобных методов, используемых для добавления информации и для доступа к ней, в том числе методы для быстрого доступа, и методы, которые при добавлении новых данных могут, по мере необходимости, автоматически выделять память. Массивы данных являются подклассами абстрактного класса vtkDataArray, что означает, что для упрощения кодирования могут быть использованы общие виртуальные методы. Однако, для более высокой производительности используются статические шаблонные функции, в которых в зависимости от типа данных происходит переключение с последующим прямым доступом к массивам с непрерывно следующими данными.

Обычно шаблоны C++ не видны в интерфейсе API с общедоступными классами, хотя они широко используются с целью повышения производительности. Это также касается STL: для того, чтобы скрыть сложность реализации шаблона от пользователей и разработчиков приложений, мы обычно используем шаблон разработки PIMPL [1]. Он нам особенно полезен в случае, когда дело доходит до обверток вокруг кода в интерпретируемом коде так, как это описано выше. Поскольку в общедоступном коде API удается избегать использовать сложные шаблоны, то это означает, что, с точки разработчиков приложений, реализация системы VTK такова, что ней не сложно выбирать типы данных. Конечно, если заглянуть глубже, то исполнение кода происходит с использованием тех типов данных, которые определяются на этапе выполнения программы, когда собственно и происходит к ним доступ.

Некоторые пользователи задаются вопросом, почему в системе VTK для управления памятью используется подсчет ссылок на данные, а не используется более удобный для пользователей подход, например, сборка мусора. Основной ответ состоит в том, что поскольку размеры данных могут быть огромными, в VTK нужен полный контроль над тем, что происходит при удалении данных. Например, объем матрицы данных размером 1000×1000×1000, размер каждого элемента в которой равен одному байту, будет составлять гигабайт данных. Достаточно неразумно ждать того момента, пока сборщик мусора решит, нужно или не нужно освобождать память от этой матрицы. В большинстве классов (подклассов класса vtkObject) в системе VTK имеется встроенная возможность подсчета ссылок. В каждом объекте есть счетчик ссылок, который инициализируется единицей, когда создается экземпляр объекта. Каждый раз, когда объект регистрируется, значение счетчика увеличивается на единицу. Аналогичным образом, когда выполняется операция, обратная регистрации (или, что эквивалентно тому, что объект удаляется), счетчик ссылок уменьшается на единицу. В конце концов счетчик ссылок на объект становится равным нулю и в этот момент происходит самоуничтожение объекта. Типичный пример выглядит следующим образом:

vtkCamera *camera = vtkCamera::New();   // счетчик ссылок равен 1
camera->Register(this);                 // счетчик ссылок равен 2
camera->Unregister(this);               // счетчик ссылок равен 1
renderer->SetActiveCamera(camera);      // счетчик ссылок равен 2
renderer->Delete();                     // счетчик ссылок равен s 1 когда renderer удаляетсяis deleted
camera->Delete();                       // camera самоуничтожается

Есть еще одна важная причина, почему в системе VTK важно подсчитывать число ссылок: предоставляется возможность эффективного копирования данных. Например, представьте себе объект данных D1, в котором находится несколько массивов данных: точки, полигоны, цвета, скаляры и текстурные координаты. Теперь представьте обработку этих данных, когда создается новый объект данных D2, который является таким же, как первый, плюс добавляются векторные данные (расположенные в точках). Расточительным подходом является полное (со всеми элементами) копирование объекта D1 в создаваемый объект D2, а затем добавление к D2 нового массива векторов данных. Либо мы создаем пустой объект D2, а затем передаем массивы из D1 в D2 (поверхностное копирование), используя подсчет ссылок для отслеживания владельца данных, и, наконец, добавляем к D2 новый массив векторов. (Прим.пер.: здесь, скорее всего идет речь о том, что создаются ссылки на массивы, а не копируются сами массивы). Последний подход позволяет избежать копирования данных, что, как мы показали ранее, имеет важное значение для хорошей системы визуализации. Как мы увидим далее в этой главе, конвейер обработки данных постоянно выполняет операции такого рода, т. е. копирует данные из входных данных алгоритма в выходные данные и, следовательно, выполняет подсчет ссылок на данные, что необходимо в системе VTK.

Конечно, есть несколько проблем, связанных с подсчетом ссылок. Иногда могут существовать циклические ссылки, когда объекты во взаимодополняющей конфигурации ссылаются друг на друга в цикле. В этом случае требуется интеллектуальное вмешательство, или, как в случае VTK, используется специальный механизм, реализованный в vtkGarbageCollector, с помощью которого происходит управление объектами, задействованных в циклах. Когда обнаруживается такой класс (предполагается, что это происходит во время разработки), класс самостоятельно регистрируется в сборщике мусора и выполняет перезагрузку своих собственных методов Register и UnRegister. Затем, при последующем удалении объекта (или выполнении операции, обратной регистрации) метод выполняет топологический анализ в сетке подсчета ссылок и ищет автономные островки из объектов, ссылающихся друг на друга. Затем эти объекты удаляются сборщиком мусора.

Большинство экземпляров в системе VTK строится через фабрику объектов, реализованную как статический элемент класса. Синтаксис типичного примера выглядит следующим образом:

vtkLight *a = vtkLight::New();

Здесь важно признать, что, в действительности, экземпляр может создаваться не классом vtkLight, он может создаваться подклассом класса vtkLight (например, vtkOpenGLLight). Есть целый ряд факторов, влияющих на фабрику объектов, наиболее важными из которых является переносимость приложений и независимость от устройств. Например, в приведенном выше мы создаем свет в сцене, для которой выполняется операция рендеринга. В конкретном приложении на конкретной платформе vtkLight::New может в результате создать свет с помощью библиотеки OpenGL, однако на других платформах потенциально возможно, что для создания света в графической системе используются другие библиотеки рендеринга. Именно поэтому производный класс, используемый для создания экземпляра объекта, является функцией, зависящей от системной информации времени выполнения. Сначала в системе VTK было множество вариантов, в том числе gl, PHIGS, Starbase, XGL и OpenGL. Хотя большинство из этих теперь исчезли, но появились новые подходы в том числе DirectX и подходы, базирующиеся на использовании графических процессоров. По прошествии времени приложения, написанные с использованием VTK, не должны были измениться поскольку разработчики определили подклассы новых конкретных устройств для класса vtkLight и других классов, используемых для рендеринга, которые поддерживают развивающуюся технологию. Другим важным использованием фабрики объектов является возможность на этапе выполнения приложения выполнять замены, повышающие производительность. Например, класс vtkImageFFT может быть заменен классом, который получает доступ к аппаратным устройствам специального назначения или к библиотеке численной обработки данных.

24.2.2. Представление данных

Одна из сильных сторон системы VTK является возможность с ее помощью представлять сложные формы данных. Эти формы данных варьируются от простых таблиц до сложных структур, таких как сетки конечных-элементов. Все эти формы данных являются подклассами класса vtkDataObject так, как это показано на рис.24.1 (обратите внимание, это частичная диаграмма наследования многие классы объектов данных).

Рис.24.1: Классы объектов данных

Одной из наиболее важных характеристик класса vtkDataObject является то, что он может быть обработан в конвейере визуализации (следующий раздел). Среди многих показанных классов, есть только несколько, которые обычно используются в большинстве реальных приложений. Класс vtkDataSet и производные классы используются для научной визуализации (рис.24.2). Например, класс vtkPolyData используется для представления полигональных сеток; класс vtkUnstructuredGrid — для представления сеток, а класс vtkImageData - для представления двухмерных и трехмерных пиксельных и воксельных данных.

Рис.24.2: Классы наборов данных

24.2.3. Конвейерная архитектура

Система VTK состоит из нескольких основных подсистем. Вероятно, подсистема в большей мере ассоциируется с пакетами визуализации, с помощью которых формируется архитектуру потоков данных/конвейеров. Концептуально конвейерная архитектура состоит из трех основных классов объектов: объектов, используемых для представления данных (объекты класса vtkDataObject — смотрите выше), объектов, используемых для обработки, преобразования, фильтрации или отображения объектов данных из одной формы в другую (vtkAlgorithm), и объектов, используемых для работы конвейера (vtkExecutive), управление которым осуществляется согласно связному графу, в котором чередуются объекты данных и процессов (т.е. конвейеров). На рис.24.3 показан типичный конвейер.

Рис.24.3: Типичный конвейер

Несмотря на концептуальную простоту, действительная реализация конвейерной архитектурой является сложной задачей. Одной из причин этого является то, что представление данных может быть сложным. Например, некоторые наборы данных состоят из иерархии или групп данных, поэтому для обработке всех данных потребуется нетривиальная итерация или рекурсия. Чтобы справиться с составной сложностью с помощью параллельной обработки (независимо от того, касается ли это совместно используемой памяти или масштабируемых подходов распределенной обработки), необходимо разбивать данных на части, причем может потребоваться, чтобы части перекрывались друг с другом с тем, чтобы можно было непрерывно вычислять граничную информацию, например, производные.

Объекты алгоритмов также вносят свои особые сложности. Для некоторых алгоритмов может потребоваться несколько входных потоков и / или они могут создавать несколько потоков выходных данных различных типов. Некоторые из них могут обрабатывать данные локально (например, вычислять центр ячейки), а для других требуется глобальная информация, например, при вычислении гистограммы. Во всех случаях, алгоритмы воспринимают свои входные данные как неизменяемые, они их только читают для того, чтобы создать свои выходные данные. Это связанно с тем, что данные могут предлагаться в качестве входных данных для нескольких алгоритмов, и будет не очень хорошо, когда один алгоритм будет портить входные данные другого алгоритма.

Наконец, сложность выполнения алгоритма может зависеть от конкретных особенностей стратегии выполнения. В некоторых случаях у нас есть возможность кэшировать промежуточные результаты, получаемые между фильтрами. Это в случае, если в конвейере что-то будет изменено, сведет к минимуму количество повторных расчетов, которые должны быть выполнены. С другой стороны, наборы данных, используемые для визуализации, могут быть огромными, и, в таком случае, мы, возможно, захотим избавиться от данных, если они больше не нужны для вычислений. Наконец, существуют сложные стратегии исполнения, например, обработка данных с несколькими вариантами точности, что требует, чтобы конвейер работал в итеративном режиме.

Чтобы продемонстрировать некоторые из этих концепций и затем объяснять конвейерную архитектуру, рассмотрим следующий пример на языке C++:

vtkPExodusIIReader *reader = vtkPExodusIIReader::New();
reader->SetFileName("exampleFile.exo");

vtkContourFilter *cont = vtkContourFilter::New();
cont->SetInputConnection(reader->GetOutputPort());
cont->SetNumberOfContours(1);
cont->SetValue(0, 200);

vtkQuadricDecimation *deci = vtkQuadricDecimation::New();
deci->SetInputConnection(cont->GetOutputPort());
deci->SetTargetReduction( 0.75 );

vtkXMLPolyDataWriter *writer = vtkXMLPolyDataWriter::New();
writer->SetInputConnection(deci->GetOuputPort());
writer->SetFileName("outputFile.vtp");
writer->Write();

В этом примере объект reader читает большой неструктурированный сеточный файл данных (или сетку). Следующий фильтр создает из сетки изоповерхность. Фильтр vtkQuadricDecimation уменьшает размер изоповерхности, которая является полигональным набором, уменьшая для этого количество составляющих элементов (т.е., уменьшая количество треугольников, представляющих собой изоконтур). Наконец, после прореживания новый файл с уменьшенным количеством данных записывается обратно на диск. Фактическая работа конвейера происходит тогда, когда объект writer вызывает метод Write (т.е. в момент запроса данных).

Как показано в этом примере, механизм выполнения конвейеров в системе VTK запускается при запросе данных. Когда некоторому процессу, например, объекту writer или mapper (объекту, осуществляющему рендеринг данных), требуются данные, он запрашивает их в качестве входа. Если во входном фильтре уже есть соответствующие данные, то фильтр просто возвращает управление выполнением в запрашиваемый процесс. Однако, если на входе нет соответствующих данных, их необходимо вычислить. Следовательно, нужно сначала запросить данных на вход. Этот процесс будет продолжаться в направлении, обратном движению данных по конвейеру, до тех пор, пока не будет достигнут фильтр или источник данных, у которого есть «соответствующие данные», или до тех пор, пока не будет достигнуто начало конвейера, после чего фильтры будут выполнены в правильном порядке, а данные поступят в то место конвейера, где они были запрошены.

Здесь следует пояснить, что означает «соответствующие данные». По умолчанию после того, как в VTK выполняется источник данных или фильтр, его выходные данные помещаются конвейером в кэш с тем, чтобы в будущем избежать ненужных вычислений. Это сделано для того, чтобы минимизировать количество вычислений и/или объем ввода/вывода за счет использования памяти, причем такое поведения является настраиваемым. В конвейере кэшируются не только объекты данных, но также метаданные об условиях, при которых эти объекты данных были получены. В этих метаданных есть метка времени (т.е. ComputeTime), которая создается в тот момент, когда объект данных был вычислен. Таким образом, в простейшем случае, «соответствующие данные» это такие данные, которые были вычислены после того, как были внесены изменения во все конвейерные объекты, находящиеся по конвейеру раньше конкретного места. Такое поведение проще продемонстрировать с помощью следующих примеров. Давайте в конце предыдущей программы VTK добавим следующие строки:

vtkXMLPolyDataWriter *writer2 = vtkXMLPolyDataWriter::New();
writer2->SetInputConnection(deci->GetOuputPort());
writer2->SetFileName("outputFile2.vtp");
writer2->Write();

Как объяснялось ранее, первый вызов writer->Write будет причиной того, что произойдет выполнение всего конвейера. Когда вызывается writer2->Write(), конвейер, когда он сравнит временную метку кэша со временем изменений прореживающего фильтра (deci), контурного фильтра и объекта reader, он поймет, что на выходе прореживающего фильтра находятся обновленные данные. Таким образом, запрос данных не должен распространяться ранее, чем до обращения к writer2. Теперь, давайте рассмотрим следующие изменения.

cont->SetValue(0, 400);

vtkXMLPolyDataWriter *writer2 = vtkXMLPolyDataWriter::New();
writer2->SetInputConnection(deci->GetOuputPort());
writer2->SetFileName("outputFile2.vtp");
writer2->Write();

Сейчас при выполнении конвейера будет понятно, что после того, как последний раз были вычислены выходные данные контурного и прореживающего фильтров, контурный фильтр был изменен. Таким образом, кэш для этих двух фильтров устарел, и эти фильтры должны быть вычислены повторно. Однако, поскольку объект reader, находящийся перед контурным фильтром, изменен не был, данные, находящиеся в его кэше, остаются действительными, и, следовательно, объект reader повторно выполняться не должен.

Сценарий, описанный здесь, является простейшим примером конвейера, выполнение которого осуществляется по запросу. Конвейер системы VTK является гораздо более сложным. Когда фильтру или процессу требуются данные, то может предоставляться дополнительная информация, указывающая конкретные подмножества данных. Например, фильтр может выполнять вспомогательный анализ, запрашивая только часть потока данных. Давайте для демонстрации изменим наш предыдущий пример.

vtkXMLPolyDataWriter *writer = vtkXMLPolyDataWriter::New();
writer->SetInputConnection(deci->GetOuputPort());
writer->SetNumberOfPieces(2);

writer->SetWritePiece(0);
writer->SetFileName("outputFile0.vtp");
writer->Write();

writer->SetWritePiece(1);
writer->SetFileName("outputFile1.vtp");
writer->Write();

Здесь объект writer делает запрос на загрузку и обработку двух частей потока данных, независимых друг от друга, который направляется к началу конвейера. Вы могли заметить, что простая логика выполнения, описанная ранее, здесь работать не будет. По этой логике когда функция Write вызывается во второй раз, конвейер не должен повторно осуществлять выполнение, т.к. ничего в начале конвейера не изменилось. Поэтому для решения этого более сложного случая, в механизме исполнения закладывается дополнительная логика, позволяющая обрабатывать частей запросов, таких как этот запрос. В действительности выполнение конвейера в системе VTK состоит из нескольких проходов. Вычисление объектов данных, на самом деле, является последним проходом. Проход, который был выполнен до этого, является запрашивающим проходом. Это тот проход, в котором потребители данных и фильтры могут сообщить в начало конвейера, что им нужно от предстоящего вычисления. В приведенном выше примере объект writer передаст на свой вход, что ему нужно часть с номером 0. Этот запрос будет, на самом деле, передан вплоть до объекта reader. Когда конвейер будет выполняться, reader будет знать, что он должен читать подмножества данных. Кроме того, в метаданных объекта будет запомнена информация о том, какая часть соответствующих данных закэширована. В следующий раз, когда фильтр запрашивает данные со своего входа, эти метаданные будут сравниваться с текущим запросом. Таким образом, в этом примере конвейер будет выполнять повторный пересчет с тем, чтобы обработать запрос других данных.

Есть несколько типов запросов, которые может делать фильтр. К ним относятся запросы конкретного шага по времени, конкретного структурного расширения или количества скрытых слоев (т.е. граничных слоев, необходимых для вычисления информации о соседних данных). Кроме того, во время запрашивающего прохода, каждый фильтр по мере того, как через него проходит запрос, может его изменять. Например, фильтр, который не в состоянии обрабатывать потоки (например, streamline фильтр) может игнорировать частичный запрос и может запросить все данные.

24.2.4. Подсистема рендеринга

На первый взгляд в системе VTK присутствует простая объектно-ориентированная модель визуализации с классами, соответствующими компонентам, с помощью которых создаются трехмерные сцены. Например, объекты vtkActor являются объектами, рендеринг которых осуществляется с помощью vtkRenderer в сочетании с vtkCamera и, возможно, с несколькими объектами vtkRenderer, существующими в vtkRenderWindow. Сцена освещается одним или несколькими объектами vtkLight. Управление положением каждого vtkActor происходит с помощью vtkTransform, а внешний вид актера определяется через vtkProperty. Наконец, геометрическое представление актера определяется с помощью vvtkMapper. Важную роль в системе VTK играют преобразователи mapper, которые обслуживают завершение обработки данных в конвейере, а также являются интерфейсом системы рендеринга. Рассмотрим следующий пример, в котором мы прореживаем данные и записываем результат в файл, а затем с помощью преобразователя mapper визуализируем их и будем с ними взаимодействовать.

vtkOBJReader *reader = vtkOBJReader::New();
reader->SetFileName("exampleFile.obj");

vtkTriangleFilter *tri = vtkTriangleFilter::New();
tri->SetInputConnection(reader->GetOutputPort());

vtkQuadricDecimation *deci = vtkQuadricDecimation::New();
deci->SetInputConnection(tri->GetOutputPort());
deci->SetTargetReduction( 0.75 );

vtkPolyDataMapper *mapper = vtkPolyDataMapper::New();
mapper->SetInputConnection(deci->GetOutputPort());

vtkActor *actor = vtkActor::New();
actor->SetMapper(mapper);

vtkRenderer *renderer = vtkRenderer::New();
renderer->AddActor(actor);

vtkRenderWindow *renWin = vtkRenderWindow::New();
renWin->AddRenderer(renderer);

vtkRenderWindowInteractor *interactor = vtkRenderWindowInteractor::New();
interactor->SetRenderWindow(renWin);

renWin->Render();

Здесь один актер, механизм рендеринга и окно рендеринга создаются с добавлением преобразователя mapper, который подсоединяет конвейер к системе рендеринга. Также обратите внимание на добавление vtkRenderWindowInteractor, экземпляры которого перехватывают события мыши и клавиатуры и транслируют их в манипуляции с камерой или в другие действия. Этот процесс трансляции определяется через vtkInteractorStyle (подробнее об этом ниже). По умолчанию настройка многих экземпляров объектов и значений данных происходит за кулисами. Например, конструируется идентичное преобразование (identity transform), а также единственный используемый по умолчанию свет (осветитель) и его свойства.

Со временем эта объектная модель стала еще более сложной. Большая часть сложности была привнесена из разработки производных классов, которые специализируются на каком-то одном из аспектов процесса рендеринга. Теперь объекты vtkActor уточняются с помощью vtkProp (подобно тому как свойства prop ищутся на каждой стадии), и есть целая куча этих свойств prop для рендеринга двухмерной графики с оверлеями, текста, специальных трехмерных объектов и даже для поддержки улучшенных методов рендеринга, например, объемного рендеринга или поддержки использования графических процессоров (смотрите рис.24.4).

Поскольку модель данных, поддерживаемая в системе VTK, существенно выросла, аналогичным образом появились различные преобразователи mapper, с помощью которых организуется интерфейс между данными и системой рендеринга. Еще одной областью значительного расширения системы является иерархия трансформаций. То, что первоначально было простой линейной матрицей преобразования размером 4×4, стало мощной иерархией, в которой поддерживаются нелинейные преобразования, в том числе преобразования сплайнов вида thin-plate. Например, исходный класс vtkPolyDataMapper имел подклассы для конкретных устройств (например, vtkOpenGLPolyDataMapper). В последние годы он был заменен сложным графическим конвейером, называемым painter-конвейером, показанным на рис.24.4.

Рис.24.4: Изображение классов

В конструкции painter-конвейера поддерживаются различные методы рендеринга данных, которые можно объединять для получения специальных эффектов. Эта возможность значительно превосходит возможности простого преобразователя vtkPolyDataMapper, который первоначально был реализован в 1994 году.

Другим важным аспектом системы визуализации является подсистема, позволяющая делать выбор. В системе VTK есть иерархия средств выбора типа picker, которые условно делятся на те, что позволяют выбирать свойства vtkProp с использованием методов, реализованных аппаратно, а не программно (например, прорисовка лучей); а также на те которые после выполнения операции выбора pick позволяют предоставлять информацию с различными уровнями детализации. Например, с помощью некоторых команд picker можно получить информацию о месте XYZ в реальном пространстве без указания, какое было выбрано свойство vtkProp; тогда как с помощью других команд можно получить не только конкретное свойство vtkProp, но конкретную точку или ячейку, из которых состоит сетка, определяющая геометрию свойств prop.

24.2.5. События и взаимодействие

Взаимодействие с данными является неотъемлемой частью визуализации. В системе VTK это происходит в различных формах. На самом простейшем уровне, пользователи могут наблюдать события и реагировать на них соответствующим образом с помощью команд (шаблон проектирования «команда/наблюдатель»). Во всех подклассах класса vtkObject поддерживаются списки наблюдателей, которые самостоятельно регистрируются в объекте. Во время регистрации, наблюдатели указывают, какое именно событие (или события) их интересует, и добавляют соответствующую команду, которая будет вызвана, если и когда происходит это событие. Чтобы увидеть, как это работает, рассмотрим следующий пример, в котором фильтр (здесь полигональный прореживающий фильтра) имеет наблюдателя, следящим за тремя событиями StartEvent (начало события), ProgressEvent (продожение события) и EndEvent (завершение события). Эти события вызываются, когда фильтр начинает выполнение, периодически во время выполнения, а затем по окончанию выполнения. Затем в классе vtkCommand есть метод Execute, который выводит соответствующую информацию, касающуюся того, сколько потребовалось времени для выполнения алгоритма:

class vtkProgressCommand : public vtkCommand
{
  public:
    static vtkProgressCommand *New() { return new vtkProgressCommand; }
    virtual void Execute(vtkObject *caller, unsigned long, void *callData)
    {
      double progress = *(static_cast(callData));
      std::cout << "Progress at " << progress<< std::endl;
    }
};

vtkCommand* pobserver = vtkProgressCommand::New();

vtkDecimatePro *deci = vtkDecimatePro::New();
deci->SetInputConnection( byu->GetOutputPort() );
deci->SetTargetReduction( 0.75 );
deci->AddObserver( vtkCommand::ProgressEvent, pobserver );

Хотя это примитивная форма взаимодействия, она является основополагающей для многих приложений, в которых используется система VTK. Например, простой код, показанный выше, может быть легко преобразован для отображения и управления линейным индикатором процесса, который есть в графическом интерфейсе. Такая подсистема «команда/наблюдатель» также будет центральной в трехмерных виджетах в системе VTK, которые являются сложными интерактивным объектами, используемыми для выполнения запросов, обработки и редактирования данных, и которые описываются ниже.

Как показано в примере, приведенном выше, важно отметить, что события в системе VTK являются предопределенными, но есть скрытая лазейка для событий, которые может определять пользователь. В классе vtkCommand определяется набор перечисляемых событий (например, vtkCommand::ProgressEvent в приведенном выше примере), а также событие, определяемое пользователем. Событие UserEvent, которое является просто интегрированным значением, обычно используемым в качестве смещения от начального значения в наборе событий, определяемых в приложении пользователем. Так, например, vtkCommand::UserEvent+100 может относиться к конкретному событию, которое не входит в набор событий, определенных в системе VTK.

С точки зрения пользователя, виджет VTK выступает на сцене в качестве актера, за исключением того, что пользователь может взаимодействовать с ним, манипулируя рукоятками или другими геометрическими элементами (манипуляции рукоятками и геометрическими элементами базируются использовании функций выбора pick, которые были описаны ранее). Взаимодействие с таким виджетом в известной степени интуитивно понятно: пользователь берется за сферические рукоятки и перемещает их, или захватывает линию и перемещает ее. Но за кулисами происходит возникновение событий (например, InteractionEvent) и приложение, запрограммированное должным образом, может отслеживать эти события, а затем принимать соответствующие меры. Например, при возникновении события vtkCommand::InteractionEvent часто происходит следующее:

vtkLW2Callback *myCallback = vtkLW2Callback::New();
  myCallback->PolyData = seeds;    // streamlines создают точки, обновляемые при взаимодействии
  myCallback->Actor = streamline;  // актер streamline становится видимым при взаимодействии

vtkLineWidget2 *lineWidget = vtkLineWidget2::New();
  lineWidget->SetInteractor(iren);
  lineWidget->SetRepresentation(rep);
  lineWidget->AddObserver(vtkCommand::InteractionEvent,myCallback);

Виджеты VTK фактически построены с использованием двух объектов: подкласса класса vtkInteractorObserver и подкласса класса vtkProp. Наблюдатель vtkInteractorObserver просто наблюдает в окне рендеринга за взаимодействием с пользователем (т.е. за событиями мыши и клавиатуры) и обрабатывает их. Манипулирование подклассами класса vtkProp (то есть, актеры) просто происходит с помощью vtkInteractorObserver. Обычно такая манипуляция включает изменение геометрии vtkProp, к которой относятся управление освещенностью, изменение вида курсора и/или преобразование данных. Конечно, конкретные особенности виджетов требуют, чтобы были написаны подклассы, позволяющие управлять нюансами его поведения, и в настоящее время в системе есть более 50 различных виджетов.

24.2.6. Краткое описание библиотек

Система VTK является большим набором программных инструментов. В настоящее время система состоит из примерно 1,5 миллионов строк кода (включая комментарии, но не включая автоматически создаваемые программные обвертки), а также из приблизительно 1000 классов на языке C++. Чтобы управлять такой сложной системой и уменьшить время, затрачиваемое на сбоку и компоновку, система была разделена на десятки подкаталогов. В таблице 24.1 перечислены эти подкаталоги и кратко описано, какие возможности представлены в каждой библиотеке.

Таблица 24.1: Подкаталоги системы VTK

Common

базовые классы системы VTK

Filtering

классы, используемые для управления потоком данных, идущих через конвейер

Rendering

рендеринг, выбор свойств, просмотр изображений и взаимодействие с изображением

VolumeRendering

технологии объемного рендеринга

Graphics

обработка трехмерной геометрии

GenericFiltering

обработка нелинейной трехмерной геометрии

Imaging

конвейер изображений

Hybrid

классы, требуемые для реализации графики и функциональных возможности работы с изображениями

Widgets

сложные варианты взаимодействий

IO

вход и выход системы VTK

Infovis

визуализация информации

Parallel

параллельная обработка (контроллеры и коммуникаторы)

Wrapping

поддержка обверток для языков Tcl, Python и Java

Examples

обширные и хорошо документированные примеры

24.3. Оглядываясь назад / заглядывая вперед

Система VTK является чрезвычайно успешной системой. Хотя первая строка кода была создана в 1993 году, на момент написания этой статьи система VTK все еще продолжает сильно расти и темпы развития увеличиваются [2]. В этом разделе мы поговорим о некоторых усвоенных уроках и будущих исследованиях.

24.3.1. Управление ростом системы

Один из самых удивительных аспектов в приключении, связанном с системой VTK, является ее долголетие. Темпы развития обусловлены несколькими основными причинами:

Рост впечатляющий, что подтверждено самим созданием системы и служит хорошим предзнаменованием будущего VTK, но все это он чрезвычайно трудно поддается управлению. В результате в ближайшем будущем фокус в системе VTK будет больше сосредоточен на управлении ростом сообщества и развитием самой системы. Есть некоторые шаги в этом направлении.

Во-первых, созданы формальные структуры управления. Совет Architecture Review Board был создан для того, чтобы направлять развитие сообщества и технологий, уделяя особое внимание стратегическим вопросам высокого уровня. Сообщество VTK также создало признанную команду лидеров Topic Leads, которая управляет развитием отдельных подсистем VTK.

Далее, есть планы на еще большую модуляризацию инструментальных средств частично в ответ на появившиеся возможности организации рабочего процесса, введенные с помощью git, но и признающие, что пользователи и разработчики обычно хотят работать с небольшими подсистемами инструментальных средств и не хотят собирать и компоновать весь пакет. Кроме того, для поддержки растущего сообщества, важно, чтобы поддерживался и тот вклад, который дают новые функциональные возможности и подсистемы даже в случае, если они не обязательно являются частью основного инструментального набора. Создавая слабо связанный набор модулей, можно добавить большое количество неосновных возможностей и сохранить стабильность базовой части.

24.3.2. Технологические дополнения

Кроме самого процесса разработки программного обеспечения, есть множество технологических инноваций, находящихся на стадии разработки

24.3.3. Наука с открытыми возможностями

Наконец, Kitware и в целом все сообщество VTK являются приверженцами науки с открытыми возможностями. С прагматической точки зрения это способ сказать, что мы будем обнародовать открытые данные, открытые публикации и открытый исходный код — то, что нужно для создания репродуцируемых научных системы. Хотя система VTK уже давно распространяется с открытым исходным кодом и является открытой системой обработки данных, в ней не хватало документации. Хотя есть достойные книги [Kit10, SML06], также должны быть различные специальные способы сбора технических публикаций, в том числе и новых добавлений исходного кода. Мы улучшаем ситуацию путем разработки новых механизмов публикации, например, создав журнал VTK Journal [3], в котором представлены статьи, содержащие документацию, исходный код, данные и актуальные тестовые изображения. Журнал также позволяет получать автоматизированные оценки кода (с использованием процесса тестирования качества программ, имеющегося в системе VTK), а также публиковать обзоры подписок, создаваемые людьми.

24.3.4. Усвоенные уроки

Хотя система VTK является успешной, есть много того, что мы делали неправильно:

Одно из самых важных, касающееся системы с открытым исходным кодом, такой как VTK, является то, что многие из этих ошибок могут быть устранены и будут со временем устранены. У нас есть активное способное развиваться сообщество, которое ежедневно совершенствует систему, и мы ожидаем, что это будет продолжаться и в обозримом будущем.

Примечания

  1. http://en.wikipedia.org/wiki/Opaque_pointer.
  2. Смотрите анализ последнего варианта кода системы VTK по ссылке http://www.ohloh.net/p/vtk/analyses/latest.
  3. http://www.midasjournal.org/?journal=35

На главную -> MyLDP -> Тематический каталог ->

Битва за Веснот

Глава 25 из 1 тома книги "Архитектура приложений с открытым исходным кодом".

Оригинал: Battle for Wesnoth, глава из книги "The Architecture of Open Source Applications" том 1.
Авторы: Richard Shimooka и David White
Дата публикации:
Перевод: Н.Ромоданов
Дата перевода: июль 2013 г.

Creative Commons. Перевод был сделан в соответствие с лицензией Creative Commons. С русским вариантом лицензии можно ознакомиться здесь.

Программирование, как правило, рассматривается как простая деятельность по решению проблемы; у разработчика есть требования и он кодирует решение. О красоте часто судят по элегантности технической реализации или по ее эффективности; данная книга изобилует прекрасными примерами. Но код, кроме собственно вычислительных функций, может иметь огромное влияние на жизнь людей. Он может вдохновить людей принять участие в проекте и создать новое содержание. К сожалению, существуют серьезные барьеры, которые мешают людям участвовать в проекте.

Для того, чтобы пользоваться большинством языков программирования, необходимы существенные технические навыки, что для многих является недоступным. Кроме того, повышение доступности кода является для многих программ технически сложным и ненужным. Это редко ведет к аккуратному кодированию скриптов или к хорошо продуманных программным решениям. Обеспечение доступа к коду требует значительной предусмотрительности при разработке проекта и программы, из-за чего часто требуется следовать стандартам, которые контринтуитивны нормальному программированию. Более того, в большинстве проектов полагаются на устоявшийся штат квалифицированных профессионалов, которые, как ожидается, работают на достаточно высоком уровне. Им не требуются дополнительные программистские ресурсы. Поэтому доступность кода, если она вообще рассматривается, становится второстепенной.

В нашем проекте, «Битва за Веснот» («Battle for Wesnoth»), с самого начала делалась попытка решить эту проблему. Данная программа является стратегической игрой в стиле фэнтези, созданной на основе модели с открытым исходным кодом и лицензией GPL2. Это был довольно внушительный успех с более, чем четырьмя миллионами скачиваний на момент написания статьи. Несмотря на такой впечатляющий показатель, мы считаем, что по-настоящему красивой гранью нашего проекта является модель разработки, позволяющая взаимодействовать и создавать свои решения группе добровольцев, обладающих крайне различными уровнями программистских навыков.

Повышение доступности кода не было всего лишь смутной целью, установленным разработчиками, а рассматривалось как условие, необходимое для выживания проекта. Подход с использованием открытого исходного кода в Wesnoth означал, что в проекте не следовало ожидать участия большого количества высококвалифицированных разработчиков. Создавая проект доступным широкому кругу участников, обладающих различной степенью квалификации, мы могли бы обеспечить ему длительную жизнестойкость.

С самых ранний итераций работы на проектом, наши разработчики попытались заложить основы для расширения доступности кода. Бесспорно, что это отразилось во всей архитектуре программы. Основные решения принимались с оглядкой, в значительной степени, на эту цель. В настоящей главе будет предоставлено подробное исследование нашей программы с особым вниманием к усилиям по увеличению доступности.

В первой части данной главы предлагается общий обзор приемов программирования, используемых в проекте, в том числе его языка, зависимостях и архитектуры. Во второй часть мы сконцентрируемся на его уникальном языке описания данных, известном как Wesnoth Markup Language (WML). В ней объясняются конкретные функции WML с акцентом их влияния на игровые элементы. В следующей части будут рассматриваться вопросы многопользовательской реализации и использования внешних программ. В части, которая завершает настоящую главу будут приведены некоторыми заключительными наблюдениями, касающиеся нашей структуры и вопросов, связанных с расширением числа участников проекта.

25.1. Обзор проекта

На момент публикации настоящей статьи базовый движок Wesnoth, написанный на языке C++, состоял примерно из 200 000 строк кода. Сюда относится игровой движок, представляющий собой примерно половину это кода, не содержащего какого-либо контента. В программе также можно задавать игровой контент при помощи уникального языка описания данных, известного как Wesnoth Markup Language (WML). Игра поставляется вместе с еще 250 000 строками кода на языке WML. На протяжении времени существования проекта это соотношение менялось. По мере того, как программа совершенствовалась, игровой контент, который был жестко запрограммирован на C++, всё больше и больше переписывался таким образом, чтобы для определения действий программы можно было использовать язык WML. На рис.25.1 приведен общий вид архитектуры программы; областями серого цвета выделены те части, которые поддерживаются разработчиками проекта Wesnoth, а те, которые выделены белым, являются внешними зависимостями.

Рис.25.1: Архитектура программы

В целом, в большинстве случаев проект старается минимизировать количество зависимостей с тем, чтобы увеличить переносимость приложения. Преимуществом этого является уменьшение сложности программы, а также то, что разработчикам не требуется изучать нюансы большого количества интерфейсов API, разработанных третьими сторонами. В то же время, осторожное использование некоторых зависимостей может, на самом деле, помочь добиться такого же эффекта. Например, в Wesnoth используется слой Simple Directmedia Layer (SDL) для работы с видео, с вводом/выводом и для обработки событий. Слой SDL был выбран за то, что он прост в использовании, и в нем предоставлен обычный интерфейс ввода/вывода, реализованный для многих платформ. Это позволяет обеспечивать переносимость множество платформ, а не кодировать на различных платформах альтернативные варианты для конкретных интерфейсов API. За это приходится платить, т.к. это оказывается сложнее, чем пользоваться преимуществами некоторых специальных возможностей, имеющихся в ряде платформ. В SDL также есть семейство дополнительных библиотек, которые в проекте Wesnoth используются для различных целей:

Кроме этого, в Wesnoth используется несколько других библиотек:

Повсюду в движке Wesnoth используются объекты WML, т.е. практически повсеместны строковые словари с дочерними элементами. Множество объектов может создаваться из элементов WML, а также может быть сериализовано в элементы WML. В некоторых частях движка данные хранятся в таком формате, который базируется на словарях WML, причем они непосредственно интерпретируются, а не преобразовываются в структуру данных языка C++.

В Wesnoth используется несколько важных подсистем, большинство из которых сделаны настолько автономными, насколько это было возможным. Такая сегментированная структура имеет преимущества для обеспечения доступности. Любой. Кому это будет интересно, может достаточно работать над кодом из конкретной части программы и вносить туда изменения, не повреждая остальную часть программы. К числу основных подсистем относятся следующие:

Также существуют модули для управления различными этапами игрового процесса:

Модуль «play game» и основной модуль отображения являются самыми большими в Wesnoth. Их назначение определено наименее четко, поскольку функции меняются постоянно и для них сложно составить четкую спецификацию. Поэтому в течение всего времени существования программы для этих модулей часто возникал риск их реализации в виде антипаттерна Blob, т. е. превращения их в громадные доминирующие сегменты без строго определенного поведения. Код в этих модулях регулярно просматривается с целью определить, можно ли какую-нибудь из его частей выделить в отдельный модуль.

Есть также вспомогательные функции, которые являются частью общего проекта, но реализованы отдельно от основной программы. К ним относится многопользовательский сервер, который позволяет подключаться к многопользовательским сетевым играм, а также сервер контента, который позволяет пользователям загружать их контент на общий сервер и делать его доступным для других. Оба они написаны на языке C++.

25.2. Wesnoth Markup Language — язык разметки Wesnoth

Поскольку Wesnoth является расширяемым игровым движком, в нем используется простой язык описания данных, позволяющий сохранять и загружать все игровые данные. Хотя сначала рассматривалось использование языка XML, мы решили, что нам нужно что-нибудь более дружественное для пользователей, которые технически подготовлены слабее, и менее строгое для описания визуальных данных. Поэтому мы разработали свой собственный язык описания данных, называемый Wesnoth Markup Language (WML). Он был разработан в расчете на пользователей, имеющих самую слабую техническую подготовку: мы надеялись, что даже те пользователи, для которых языки Python и HTML кажутся страшными, смогут разобраться в файле WML. Все игровые данные Wesnoth хранятся на языке WML, в том числе определения юнитов, описания кампаний, сценарии, определения пользовательского интерфейса и другие настройки игровой логики.

В языке WML используются такие же самые базовые элементы, что и в языке XML: элементы и атрибуты, хотя в нем не поддерживается использование текста внутри элементов. Атрибуты WML представлены просто как словарь, осуществляющий отображение строк в строки, а за интерпретацию атрибутов отвечает логика программы. Ниже показан простой пример на языке WML, с помощью которого в игре приводится сокращенное определение юнита Elvish Fighter (Эльфийский Воин):

[unit_type]
    id=Elvish Fighter
    name= _ "Elvish Fighter"
    race=elf
    image="units/elves-wood/fighter.png"
    profile="portraits/elves/fighter.png"
    hitpoints=33
    movement_type=woodland
    movement=5
    experience=40
    level=1
    alignment=neutral
    advances_to=Elvish Captain,Elvish Hero
    cost=14
    usage=fighter
    {LESS_NIMBLE_ELF}
    [attack]
        name=sword
        description=_"sword"
        icon=attacks/sword-elven.png
        type=blade
        range=melee
        damage=5
        number=4
    [/attack]
[/unit_type]

Поскольку в Wesnoth очень важна интернационализация, в языке WML есть ее непосредственная поддержка: значения атрибутов, начинающиеся со знака подчеркивания, являются переводимыми. Когда происходит синтаксический разбор языка WML, все переводимые строки с помощью GNU gettext преобразовываются в переводенные версии строк.

Вместо того, чтобы использовать много различных документов WML, в Wesnoth выбран подход, в котором все основные игровые данные передаются в игровой движок просто в виде единого документа. Это позволяет для работы с документом использовать только одну глобальную переменную, а когда в игре происходит загрузка, например, определения юнитов, выполнять поиск элементов с именем unit_type}, находящихся внутри элемента units.

Не смотря на то, что все данные хранятся в виде одного концептуально единого документа WML, было бы громоздко хранить весь этот документ в одном файле. Поэтому в Wesnoth используется препроцессор, которыйперед тем, как начнется синтаксический разбор, проходит по всем файлам WML. Этот препроцессор позволяет в файл добавлять содержимое другого файла или целого каталога. Например:

{gui/default/window/}

будет добавлять содержимое всех файлов .cfg, находящихся в каталоге gui/default/window/.

Поскольку описание в WML может быть очень подробным, в препроцессоре также можно использовать макросы, которые нужны для более компактных определений. Например, обращение к LESS_NIMBLE_ELF в определении Эльфийского Воина (Elvish Fighter) является вызовом макроса, который делает некоторых эльфийских юнитов менее ловкими при определенных условиях, например, когда они размещены в лесу:

#define LESS_NIMBLE_ELF
    [defense]
        forest=40
    [/defense]
#enddef

Данная конструкция обладает тем преимуществом, что движок не считает нужным разбираться с тем, как документ WML разбит на отдельные файлы. Это ответственность лежит на авторах документа WML, которые решают, как структурировать документ и как разделить его на отдельные файлы и каталоги.

Когда игровой движок загружает документ WML, он, в соответствии с различными настройками игры, также определяет некоторые значения настроек для препроцессора. Например, для кампании в Wesnoth можно определять различные уровни сложности, причем различные настройки каждого из них приведут к тому, что для препроцессора будут определены различные значения настроек. Например, обычным способом изменения сложности игры является изменение количества ресурсов, предоставляемых оппоненту (определяемых количеством золота). Чтобы это упростить, используется макрос, который определен следующим образом:

#define GOLD EASY_AMOUNT NORMAL_AMOUNT HARD_AMOUNT
  #ifdef EASY
    gold={EASY_AMOUNT}
  #endif
  #ifdef NORMAL
    gold={NORMAL_AMOUNT}
  #endif
  #ifdef HARD
    gold={HARD_AMOUNT}
  #endif
#enddef

Этот макрос может быть вызван, например, внутри определения оппонента как {GOLD 50 100 200} с тем, чтобы указать, сколько в зависимости от уровня сложности он получит золота.

Поскольку XML обрабатывается в зависимости от настроек, то если какое-либо значение, прилагаемое к документу WML, изменится во время его исполнения движком Wesnoth, весь документ WML должен быть повторно загружен и обработан. Например, когда пользователь запускает игру, то загружается документ WML и среди всего прочего загружается и список доступных кампаний. Но, если затем пользователь выбирает кампанию и выбирает определенный уровень сложности, например, легкий уровень, то весь документ будет перезагружен с установленным параметром EASY.

Такая конструкция удобна тем, что в одном документе содержатся все игровые данные и что с помощью значений настроек можно легко сконфигурировать документ WML. Но в Wesnoth, как у успешного проекта, появляется всё больше и больше контента, в том числе контента, доступного для загрузки, который, в конце концов, весь оказывается в дереве основного документа, что означает, что размер окончательно получившегося документа WML будет составлять много мегабайтов. Из-за этого возникают проблемы с производительностью Wesnoth: на некоторых компьютерах загрузка документа может доходить до минуты, приводя в игре к задержкам каждый раз, когда документ необходимо перезагрузить. Кроме того, при этом используется значительный объем памяти. Против этого предпринимаются некоторые меры: когда кампания загружается, то для этой капании есть уникальное значение, определяемое в препроцессоре. Это означает, что когда нужна эта кампания, то будет использован весь тот контент, принадлежность которого к кампании определяется с помощью #ifdef.

Кроме того, в Wesnoth используется система кеширования, которая кеширует препроцессорную версию документа WML для заданного набора ключевых определений. Естественно, что такая кеширующая система должна проверять даты изменения файлов WML для того, чтобы в случае, если какой-нибудь из них будет изменен, перегенерировать закешированный документ.

25.3. Юниты в Wesnoth

Главными героями в Wesnoth являются его юниты. Эльфийский Воин (Elvish Fighter) и Эльфийский Шаман (Elvish Shaman) могут сражаться против Воина-Тролля (Troll Warrior) и Орка-Пехотинца (Orcish Grunt). У всех юнитов одинаковое базовое поведение, но многие из них имеют специальные способности, которые изменяют нормальный ход игры. Например, тролль с каждым ходом может регенерировать часть своего здоровья, Эльфийский Шаман может замедлять действия своих противников с помощью запутывающих корней, а Лешего (Wose) нельзя увидеть, когда тот находится в лесу.

Как лучше всего представить это в движке? Заманчиво создать базовый класс uni} на языке C++, от которого наследовать различные виды юнитов. Например, класс wose_unit можно было бы наследовать из unit, и unit мог бы иметь виртуальную функцию bool is_invisible() const, которая возвращает значение \code{false}, и которую wose_unit переопределяет с тем, чтобы она возвращала значение {true в случае, если данный юнит сейчас находится в лесу.

Такой подход работал бы довольно хорошо в игре с ограниченным набором правил. К сожалению, Wesnoth является довольно большой игрой, а такой подход нельзя достаточно просто расширить. Если кто-нибудь в случае использования такого подхода захочет добавить новый вид юнитов, то для этого потребуется добавление в игру нового класса C++. Кроме того, такой подход не позволяет достаточно хорошо комбинировать различные характеристики: что если бы у вас был юнит, который умеет регенерировать, замедлять врагов при помощи сетки, и который невидим в лесу? Вам бы пришлось написать совершенно новый класс, в котором бы дублировался код других классов.

Система юнитов, имеющаяся в Wesnoth, для того, чтобы справиться с этой задачей, совсем не использует наследование. Вместо этого используется класс unit, в котором представлены экземпляры юнитов, а также класс unit_type, с помощью которого представлены неизменяемые характеристики, которыми обладают все юниты определенного типа. Класс {unit содержит ссылку на тип объекта, которым он является. Все возможные объекты класса unit_type хранятся в глобальном словаре, загружаемом при загрузке главного документа WML.

Тип юнита содержит список всех способностей данного юнита. Например, у Тролля есть способность «регенерация», которая позволяет ему с каждым ходом восстанавливать жизни. У Саурийского стрелка (Saurian Skirmisher) есть способность «стрельба», которая позволяет ему пробиваться сквозь цепи врагов. Распознавание этих способностей встроено в движок, например, алгоритмы поиска пути будут проверять, выставлен ли у юнита флаг «стрельба» с тем, чтобы знать может ли юнит пробиваться сквозь цепи врагов. Такой подход позволяет с помощью только редактирования текста на WML добавлять новые юниты, у которых может быть любая комбинация способностей, определенных в движке. Конечно, при этом не допускается без модификации движка добавлять совершенно новые способности и поведение юнитов.

Кроме того, каждый юнит в Wesnoth может обладать любым количеством способов атак. Например, Эльфийский Лучник (Elvish Archer) может, стреляя из лука, атаковать на дальние расстояния, а вблизи атаковать используя меч. Каждая из атак наносит различный урон различного вида и различной величины. Для представления типов атак существует класс attack_type и У каждого экземпляра класса unit_type есть список возможных типов атак в виде списка типов attack_type.

Чтобы обеспечить каждому юниту больше индивидуальности, в Wesnoth есть такое понятие, как «особенности». В момент рекрутинга большинству юнитов назначаются две особенности, выбираемые из предопределенного списка случайным образом. Например, сильные юниты наносят больше урона при ближней атаке, в то время как умным юнитам потребуется набирать меньше опыта для того, чтобы перейти на следующий уровень. Также юниты могут по ходу игры получать «снаряжение», которое может сделать их более сильными. Например, это может быть меч, поднятый юнитом, с которым его атаки будут наносить больший урон. Чтобы реализовать «особенности» и «снаряжение», в Wesnoth разрешается определять модификации для юнитов, которые являются определенными с помощью WML вариациями статистических свойств юнита. Например, особенность-сила позволяет сильным юнитам наносить больший урон вблизи, но не в случаях дальних атак.

Разрешить полностью определять поведение юнитов с помощью языка WML могло бы быть замечательной целью, поэтому было бы правильным объяснить, почему в Wesnoth эта цель никогда не была достигнута. Если бы было разрешено произвольное поведение юнитов, то язык WML должен был бы быть намного более гибкими, чем он есть сейчас. Вместо того, чтобы использовать WML как язык, предназначенный для описания данных, его бы пришлось так расширить, что он бы стал полноценным языком программирования, что отпугнуло бы многих участников проекта.

Кроме того, в игре участвует модуль искусственного интеллекта Wesnoth AI, написанный на языке C++, который распознаёт различные способности юнитов. Он учитывает регенерацию, невидимость, и т.д., и старается управлять своими юнитами так, чтобы получать наибольшую выгоду от этих способностей. Даже если бы новые способности юнитов можно было добавлять с помощью языка WML, было бы очень сложно сделать модуль искусственного интеллекта настолько умным, чтобы распознавать и использовать такие способности. Реализация способностей была бы неполной, если бы модуль искусственного интеллекта не мог бы их использовать. Также было бы странным, если бы после определения новой способности на языке WML приходилось бы изменять код модуля искусственного интеллекта, написанный на языке C++. Поэтому наличие юнитов, определяемых на языке WML, но обладающих способностями, жестко запрограммированными в движке, является разумным компромиссом, который в специфических условиях проекта Wesnoth работает лучше всего.

25.4. Реализация в Wesnoth многопользовательских игр

В многопользовательском варианте игры в Wesnoth используется максимально простая реализация. В ней делаются попытки уменьшить вероятность вредоносных атак на сервер, но не слишком много делается с целью предотвращать мошенничество в игре. Каждый ход в Wesnoth, будь то перемещение юнита, атака врага, рекрутинг нового юнита и т.д., может быть представлен в виде элемента языка WML. Например, команда перемещения юнита может быть представлена в виде WML следующим образом:

[move]
    x="11,11,10,9,8,7"
    y="6,7,7,8,8,9"
[/move]

Здесь показан путь, по которому движется юнит в результате выполнения команд, отдаваемых игроком. Далее, у игры есть возможность выполнять любые команды WML, которые она получает. Это очень удобно, поскольку если сохранить начальное состояние игры, а также все последующие команды, то может быть сохранен весь ход игры. Возможность повторять весь ход игр полезна как для самих игроков, чтобы можно было наблюдать за тем, как играет кто-то другой, так и для того, чтобы делать сообщения об ошибках определенного вида.

При реализации в Wesnoth многопользовательских игр, мы решили, что сообщество будет пытаться сосредоточиться на дружественных, казуальных играх. Вместо того, чтобы вступать в техническую борьбу с антиобщественными кракерами, пытающимися обмануть систему профилактики взлома, проект просто не будет стараться предотвращать мошенничество. Анализ других многопользовательских игр показал, что конкурентоспособные системы рейтинга были ключевым источником антиобщественного поведения. Умышленное отсутствие таких функций на сервере значительно снижает мотивацию у тех, кто пытается обмануть. Кроме того, модераторы стараются поощрять более позитивные отношения в игровом сообществе, когда игроки находят личное взаимопонимание с другими игроками, а затем играют с ними. В результате акцент перемещается с конкуренции на взаимоотношения. Результат данных попыток был признан успешным, т.к. количество попыток вредоносного взлома игры значительно сократилось.

Реализация многопользовательской игры в Wesnoth представляет собой типичную клиент-серверную инфраструктуру. Сервер, также известный как wesnothd, принимает соединение от клиента Wesnoth и отсылает клиенту краткое описание доступных игр. Wesnoth покажет игроку экран главного меню, где игрок сможет присоединиться к существующей игре или создать новую игру, к которой присоединятся другие. Когда все игроки присоединились к игре и игра началась, каждый клиент Wesnoth будет генерировать команды WML, описывающие действия, которые совершает игрок. Эти команды каждый клиент отсылает на сервер, а затем сервер передает их другим клиентам, участвующим в игре. Таким образом, сервер выступает в роли очень простого и легковесного ретранслятора. Затем на других клиентах используется система проигрывания команд для того, чтобы выполнять полученные команды WML. Поскольку Wesnoth является пошаговой игрой, для всех сетевых коммуникаций используется протокол TCP/IP.

Данная система также легко позволяет наблюдателям следить за игрой. Наблюдатель может присоединиться к игре после того, как она началась. В этом случае сервер передаст наблюдателю WML, описывающий начальное состояние игры, а затем историю всех команд, которые были выполнены после начала игры. Это позволяет новым наблюдателям увидеть, что происходило в игре ранее. Они могут видеть всю историю игры, однако требуется некоторое время, чтобы наблюдатель мог догнать текущее состояние игры. Историю команд можно прокручивать в ускоренном режиме, но всё равно это занимает некоторое время. Альтернативой могло бы быть, если бы один из клиентов генерировал снимок текущего состояния игры в виде WML и отсылал бы его новому наблюдателю, однако такой подход обременял бы клиентов накладными расходами, связанными с наличием наблюдателей, и мог бы способствовать возникновению состояний типа DoS-атак в случае, когда к игре присоединялось бы большое количество наблюдателей.

Конечно, поскольку клиенты Wesnoth не пользуются совместно каким-либо игровым статусом, а только отсылают команды, очень важно чтобы все они играли по одним правилам. Сервер разделяет клиентов по версиям, и друг с другом могут играть только игроки с одинаковыми версиями игры. Игроки сразу же получают уведомление в случае, если их версия игры устарела в сравнении с другими. Это также является полезным способом предотвращения мошенничества. Не смотря на то, что клиенту легко смошенничать, изменив своего клиента, о любом различии в версиях сразу будет сообщено игрокам, участвующим в игре.

25.5. Заключение

Нам кажется, что красота Battle for Wesnoth, как программы, состоит в том, насколько участие в ее развитии сделано доступным для широкого круга участников. Чтобы достичь этой цели, проект часто шел на компромиссы, из-за которых код выглядит совсем не элегантно. Важно заметить, что многие из более талантливых программистов проекта относятся к языку WML с неодобрением, из-за неэффективности его синтаксиса. Тем не менее, этот компромисс лежит в основе одного из самых больших достижений проекта. Сейчас Wesnoth может похвастаться сотнями игр с различными кампаниями и разнообразными эпохами, написанными пользователями, у которых до этого не было или почти не было опыта в программировании. Более того, это вдохновило многих, использовавших данный проект как средство обучения, выбрать своей профессией программирование. Всё это является вполне осязаемыми достижениями, с которыми могут сравниться лишь некоторые программы.

Одним из ключевых уроков, которые читателю стоит вынести из истории с Wesnoth, это учет тех проблем, с которыми сталкиваются менее опытные программисты. Необходимо осознавать, что именно препятствует участникам проекта действительно писать код и развивать свои навыки. Например, кто-то хотел бы помогать развиваться программе, но он не обладает какими-либо навыками в программировании. Специальные технологические текстовые редакторы, такие как \code{emacs} или \code{vim}, обладают значительной кривой обучения, которая может показаться для такого участника пугающей. Поэтому был разработан язык WML, файлы на котором можно открывать в обычном текстовом редакторе, что дает всем желающим возможность принять участие в проекте.

Однако, увеличение доступности кода не является легко достижимой целью. Нет простых и быстрых правил для улучшения доступности кода. Скорее всего для этого требуется баланс между различными факторами, которые могут иметь негативные последствия, о чем должно быть осведомлено сообщество проекта. Это становится очевидным, если посмотреть на то, как наша программа справлялась с зависимостями. В одних случаях зависимости могут повысить порог вхождения, а в других - они могут облегчить участие в проекте. Каждый такой случай нужно рассматривать отдельно.

Также нужно быть аккуратным с тем, чтобы не переоценить некоторые из успехов проекта Wesnoth. В проекте можно было воспользоваться некоторыми преимуществами, которые, скорее всего, не удастся воспроизвести в других программах. Доступность кода для более широкого круга участников является частично результатом того, что в программе есть возможности ее настройки. В этом плане в Wesnoth были некоторые преимущества, как у проекта с открытым исходным кодом. Юридически лицензия GNU позволяет любому открыть существующий файл, разобраться в том, как он работает, и внести изменения. В этой культуре поощряются экспериментирование, изучение и совместное использование знаний, что может оказаться неприемлемым для других программ. Не смотря ни на что, мы надеемся, что определенные элементы все-таки пригодятся всем разработчикам и помогут им в их попытках отыскать красоту в программировании.

1.1 Принципы построения распределенных веб-систем

Что именно означает создание и управление масштабируемым веб-сайтом или приложением? На примитивном уровне это просто соединение пользователей с удаленными ресурсами через Интернет. А ресурсы или доступ к этим ресурсам, которые рассредоточены на множестве серверов и являются звеном, обеспечивающим масштабируемость веб-сайта.

Как большинство вещей в жизни, время, потраченное заранее на планирование построения веб-службы, может помочь в дальнейшем; понимание некоторых соображений и компромиссов, стоящих позади больших веб-сайтов, может принести плоды в виде более умных решений при создании меньших веб-сайтов. Ниже некоторые ключевые принципы, влияющие на проектирование крупномасштабных веб-систем:

Каждый из этих принципов является основой для принятия решений в проектировании распределенной веб-архитектуры. Тем не менее, они также могут находиться в противоречии друг с другом, потому что достижение целей одного происходит за счет пренебрежения другими. Простой пример: выбор простого добавления нескольких серверов в качестве решения производительности (масштабируемость) может увеличивать затраты на управляемость (вы должны эксплуатировать дополнительный сервер) и покупку серверов.

При разработке любого вида веб-приложения важно рассмотреть эти ключевые принципы, даже если это должно подтвердить, что проект может пожертвовать одним или несколькими из них.

1.2 Основы

При рассмотрении архитектуры системы есть несколько вопросов, которые необходимо осветить, например: какие компоненты стоит использовать, как они совмещаются друг с другом, и на какие компромиссы можно пойти. Вложение денег в масштабирование без очевидной необходимости в ней не может считаться разумным деловым решением. Однако, некоторая предусмотрительность в планировании может существенно сэкономить время и ресурсы в будущем.

Данный раздел посвящается некоторым базовым факторам, которые являются важнейшими для почти всех больших веб-приложений: сервисы, избыточность, сегментирование, и обработка отказов. Каждый из этих факторов предполагает выбор и компромиссы, особенно в контексте принципов, описанных в предыдущем разделе. Для пояснения приведем пример.

Пример: Приложение хостинга изображений

Вы, вероятно, когда-либо уже размещали изображения в сети. Для больших сайтов, которые обеспечивают хранение и доставку множества изображений, есть проблемы в создании экономически эффективной, высоконадежной архитектуры, которая характеризуется низкими задержками ответов (быстрое извлечение).

Вообразите систему, где пользователи имеют возможность загрузить свои изображения на центральный сервер, и при этом изображения могут запрашиваться через ссылку на сайт или API, аналогично Flickr или Picasa. Для упрощения описания давайте предположим, что у этого приложения есть две основные задачи: возможность загружать (записывать) изображения на сервер и запрашивать изображения. Безусловно, эффективная загрузка является важным критерием, однако приоритетом будет быстрая доставка по запросу пользователей (например, изображения могут быть запрошены для отображения на веб-странице или другим приложением). Эта функциональность аналогична той, которую может обеспечить веб-сервер или граничный сервер Сети доставки контента (Content Delivery Network, CDN). Сервер CDN обычно хранит объекты данных во многих расположениях, таким образом, их географическое/физическое размещение оказывается ближе к пользователям, что приводит к росту производительности.

Другие важные аспекты системы:

Рисунок 1.1 представляет собой упрощенную схему функциональности.


Рисунок 1.1: Упрощенная схема архитектуры для приложения хостинга изображений

В этом примере хостинга изображений система должна быть заметно быстрой, ее данные надежно сохранены, и все эти атрибуты хорошо масштабируемы. Создание небольшой версии этого приложения представляло бы из себя стандартную задачу, и для его размещения достаточно было бы единственного сервера. Однако, подобная ситуация не представляла бы интереса для данной главы. Давайте предположим, что нам необходимо создать что-то такое же масштабное, как Flickr.

Сервисы

При рассмотрении дизайна масштабируемой системы, бывает полезным разделить функциональность и подумать о каждой части системы как об отдельной службе с четко определенным интерфейсом. На практике считается, что системы разработанные таким образом имеют Service-Oriented Architecture (SOA). Для этих типов систем у каждой службы существует свой собственный отличный функциональный контекст, и взаимодействие с чем-либо за пределами этого контекста происходит через абстрактный интерфейс, обычно общедоступный API другой службы.

Деконструкция системы на ряд комплементарных сервисов изолирует работу одних частей от других. Эта абстракция помогает устанавливать четкие отношения между службой, ее базовой средой и потребителями службы. Создание четкой схемы может помочь локализовать проблемы, но также и позволяет каждой части масштабироваться независимо друг от друга. Этот вид сервисно-ориентированного дизайна систем для обслуживания широкого круга запросов аналогичен объектно-ориентированному подходу в программировании.

В нашем примере все запросы загрузки и получения изображения обработаны одним и тем же сервером; однако, поскольку система должна масштабироваться, целесообразно выделить эти две функции в их собственные сервисы.

Предположим, что в будущем служба находится в интенсивном использовании; такой сценарий помогает лучше проследить, как более длительные записи влияют на время для считывания изображения (так как две функции будут конкурировать за совместно используемые ресурсы). В зависимости от архитектуры этот эффект может быть существенным. Даже если скорость отдачи и приема будут одинаковы (что не характерно для большинства сетей IP, поскольку они разработаны для соотношения скорости приема к скорости отдачи как минимум 3:1), считываемые файлы будут обычно извлекаться из кэша, и записи должны, в конечном счете, попасть на диск (и возможно подвергнуться многократной перезаписи в похожих ситуациях). Даже если все данные будут в памяти или читаться с дисков (таких как твердотельные диски SSD), то записи в базу данных почти всегда будут медленнее, чем чтения из нее. (Pole Position, инструмент с открытым исходным кодом для сравнительного тестирования баз данных, http://polepos.org/ и результаты http://polepos.sourceforge.net/results/PolePositionClientServer.pdf.).

Другая потенциальная проблема с этим дизайном состоит в том, что у веб-сервера, такого как Apache или lighttpd обычно существует верхний предел количества одновременных соединений, которые он в состоянии обслужить (значение по умолчанию - приблизительно 500, но оно может быть намного выше), и при высоком трафике записи могут быстро израсходовать этот предел. Так как чтения могут быть асинхронными или использовать в своих интересах другую оптимизацию производительности как gzip-сжатие или передача с делением на порции, веб-сервер может переключить чтения подачи быстрее и переключиться между клиентами, обслуживая гораздо больше запросов, чем максимальное число соединений (с Apache и максимальным количеством соединений, установленном в 500, вполне реально обслуживать несколько тысяч запросов чтения в секунду). Записи, с другой стороны, имеют тенденцию поддерживать открытое соединение на протяжении всего времени загрузки. Так передача файла размером 1 МБ на сервер могла занять больше 1 секунды в большинстве домашних сетей, в результате веб-сервер сможет обработать только 500 таких одновременных записей.


Рисунок 1.2: Разделение чтения и записи


Предвидение подобной потенциальной проблемы свидетельствует о необходимости разделения чтения и записи изображений в независимые службы, показанные на рисунке 1.2. Это позволит не только масштабировать каждую из них по отдельности (так как вероятно, что мы будем всегда делать больше чтений, чем записей), но и быть в курсе того, что происходит в каждой службе. Наконец, это разграничит проблемы способные возникнуть в будущем, что упростит диагностику и оценку проблемы медленного доступа на чтение.

Преимущество этого подхода состоит в том, что мы в состоянии решить проблемы независимо друг от друга - при этом нам не придется думать о необходимости записи и получении новых изображений в одном контексте. Обе из этих служб все еще используют глобальный корпус изображений, но при использовании методов соответствующих определенной службе, они способны оптимизировать свою собственную производительность (например, помещая запросы в очередь, или кэшируя популярные изображения - более подробно об этом речь пойдет далее). Как с точки зрения обслуживания, так и стоимости каждая служба может быть масштабирована независимо по мере необходимости. И это является положительным фактором, поскольку их объединение и смешивание могло бы непреднамеренно влиять на их производительность, как в сценарии, описанном выше.

Конечно, работа вышеупомянутой модели будет оптимальной, в случае наличия двух различных конечных точек (фактически, это очень похоже на несколько реализаций провайдеров "облачного" хранилища и Сетей доставки контента). Существует много способов решения подобных проблем, и в каждом случае можно найти компромисс.

К примеру, Flickr решает эту проблему чтения-записи, распределяя пользователи между разными модулями, таким образом, что каждый модуль может обслуживать только ограниченное число определенных пользователей, и когда количество пользователи увеличиваются, больше модулей добавляется к кластеру (см. презентацию масштабирования Flickr,
http://mysqldba.blogspot.com/2008/04/mysql-uc-2007-presentation-file.html). В первом примере проще масштабировать аппаратные средства на основе фактической нагрузки использования (число чтений и записей во всей системе), тогда как масштабировние Flickr просиходит на основе базы пользователей(однако, здесь используется предположение равномерного использования у разных пользователей, таким образом, мощность нужно планировать с запасом). В прошлом недоступность или проблема с одной из служб приводили в нерабочее состояние функциональность целой системы (например, никто не может записать файлы), тогда недоступность одного из модулей Flickr будет влиять только на пользователей, относящихся к нему. В первом примере проще выполнить операции с целым набором данных - например, обновляя службу записи, чтобы включить новые метаданные, или выполняя поиск по всем метаданным изображений - тогда как с архитектурой Flickr каждый модуль должен был быть подвергнут обновлению или поиску (или поисковая служба должна быть создана, чтобы сортировать те метаданные, которые фактически для этого и предназначены).

Что касается этих систем - не существует никакой панацеи, но всегда следует исходить из принципов, описанных в начале этой главы: определить системные потребности (нагрузка операциями "чтения" или "записи" или всем сразу, уровень параллелизма, запросы по наборам данных, диапазоны, сортировки, и т.д.), провести сравнительное эталонное тестирование различных альтернатив, понять условия потенциального сбоя системы и разработать комплексный план на случай возникновения отказа.

На главную -> MyLDP -> Тематический каталог ->

Масштабируемая Веб-архитектура и распределенные системы

Глава 1 из книги "Архитектура приложений с открытым исходным кодом", том 2.
Оригинал: "Scalable Web Architecture and Distributed Systems", глава из книги "The Architecture of Open Source Applications" том 2.
Автор: Кейт Мэтсудейра,
Перевод: © jedi-to-be. Коррекция: Anastasiaf15, sunshine_lass, Amaliya, fireball, Goudron.
Перевод впервые был опубликован на сайте Хабрахабр

Избыточность

Чтобы элегантно справится с отказом, у веб-архитектуры должна быть избыточность ее служб и данных. Например, в случае наличия лишь одной копии файла, хранившегося на единственном сервере, потеря этого сервера будет означать потерю и файла. Вряд ли подобную ситуацию можно положительно охарактеризовать, и обычно ее можно избежать путем создания множественных или резервных копии.

Этот тот же принцип применим и к службам. От отказа единственного узла можно защититься, если предусмотреть неотъемлемую часть функциональности для приложения, гарантирующую одновременную работу его нескольких копий или версий.

Создание избыточности в системе позволяет избавиться от слабых мест и обеспечить резервную или избыточную функциональность на случай нештатной ситуации. Например, в случае наличия двух экземпляров одной и той же службы, работающей в "продакшн", и один из них выходит из строя полностью или частично, система может преодолеть отказ за счет переключения на исправный экземпляр.
Переключение может происходить автоматически или потребовать ручного вмешательства..

Другая ключевая роль избыточности службы - создание архитектуры, не предусматривающей разделения ресурсов. С этой архитектурой каждый узел в состоянии работать самостоятельно и, более того, в отсутствие центрального , управляющего состояниями или координирующего действия других узлов. Она способствует масштабируемости, так как добавление новых узлов не требует специальных условий или знаний. И что наиболее важно, в этих системах не найдется никакой критически уязвимой точки отказа, что делает их намного более эластичными к отказу..

Например, в нашем приложении сервера изображения, все изображения имели бы избыточные копии где-нибудь в другой части аппаратных средств (идеально - с различным географическим местоположением в случае такой катастрофы, как землетрясение или пожар в центре обработки данных), и службы получения доступа к изображениям будут избыточны, при том, что все они потенциально будут обслуживать запросы. (См. рисунок 1.3.)
Забегая вперед, балансировщики нагрузки - отличный способ сделать это возможным, но подробнее об этом ниже.


Рисунок 1.3: Приложение хостинга изображений с избыточностью

Сегментирование

Наборы данных могут быть настолько большими, что их невозможно будет разместить на одном сервере. Может также случиться, что вычислительные операции потребуют слишком больших компьютерных ресурсов, уменьшая производительность и делая необходимым увеличение мощности. В любом случае у вас есть два варианта: вертикальное или горизонтальное масштабирование.

Вертикальное масштабирование предполагает добавление большего количества ресурсов к отдельному серверу. Так, для очень большого набора данных это означало бы добавление большего количества (или большего объема) жестких дисков, и таким образом весь набор данных мог бы разместиться на одном сервере. В случае вычислительных операций это означало бы перемещение вычислений в более крупный сервер с более быстрым ЦП или большим количеством памяти. В любом случае, вертикальное масштабирование выполняется для того, чтобы сделать отдельный ресурс вычислительной системы способным к дополнительной обработке данных.

Горизонтальное масштабирование, с другой стороны, предполагает добавление большего количества узлов. В случае большого набора данных это означало бы добавление второго сервера для хранения части всего объема данных, а для вычислительного ресурса это означало бы разделение работы или загрузки через некоторые дополнительные узлы. Чтобы в полной мере воспользоваться потенциалом горизонтального масштабирования, его необходимо реализовать как внутренний принцип разработки архитектуры системы. В противном случае изменение и выделение контекста, необходимого для горизонтального масштабирования может оказаться проблематичным.

Наиболее распространенным методом горизонтального масштабирования считается разделение служб на сегменты или модули. Их можно распределить таким образом, что каждый логический набор функциональности будет работать отдельно. Это можно сделать по географическими границами, или другим критериям таким, как платящие и не платящие пользователи. Преимущество этих схем состоит в том, что они предоставляют услугу или хранилище данных с расширенной функциональностью.

В нашем примере сервера изображения, возможно, что единственный файловый сервер, используемый для хранения изображения, можно заменить множеством файловых серверов, при этом каждый из них будет содержать свой собственный уникальный набор изображений. (См. рисунок 1.4.) Такая архитектура позволит системе заполнять каждый файловый сервер изображениями, добавляя дополнительные серверы, по мере заполнения дискового пространства. Дизайн потребует схемы именования, которая свяжет имя файла изображения с содержащим его сервером. Имя изображения может быть сформировано из консистентной схемы хеширования, привязанной к серверам. Или альтернативно, каждое изображение может иметь инкрементный идентификатор, что позволит службе доставки при запросе изображения обработать только диапазон идентификаторов, привязанных к каждому серверу (в качестве индекса).


Рисунок 1.4: Приложение хостинга изображений с избыточностью и сегментированием


Конечно, есть трудности в распределении данных или функциональности на множество серверов. Один из ключевых вопросов - местоположение данных; в распределенных системах, чем ближе данные к месту проведения операций или точке вычисления, тем лучше производительность системы. Следовательно, распределение данных на множество серверов потенциально проблематично, так как в любой момент, когда эти данные могут понадобиться, появляется риск того, что их может не оказаться по месту требования, серверу придется выполнить затратную выборку необходимой информации по сети.

Другая потенциальная проблема возникает в форме несогласованности (неконсистетности). Когда различные сервисы выполняют считывание и запись на совместно используемом ресурсе, потенциально другой службе или хранилище данных, существует возможность возникновения условий "состязания" - где некоторые данные считаются обновленными до актуального состояния, но в реальности их считывание происходит до момента актуализации - и таком случае данные неконсистентны. Например, в сценарии хостинга изображений, состояние состязания могло бы возникнуть в случае, если бы один клиент отправил запрос обновления изображения собаки с изменением заголовка "Собака" на "Гизмо", в тот момент, когда другой клиент считывал изображение. В такой ситуации неясно, какой именно заголовок, "Собака" или "Гизмо", был бы получен вторым клиентом..

Есть, конечно, некоторые препятствия, связанные с сегментированием данных, но сегментирование позволяет выделять каждую из проблем из других: по данным, по загрузке, по образцам использования, и т.д. в управляемые блоки. Это может помочь с масштабируемостью и управляемостью, но риск все равно присутствует. Есть много способов уменьшения риска и обработки сбоев; однако, в интересах краткости они не охвачены в этой главе. Если Вы хотите получить больше информации по данной теме, вам следует взглянуть на блог-пост по отказоустойчивости и мониторингу.

1.3. Структурные компоненты быстрого и масштабируемого доступа к данным


Рассмотрев некоторые базовые принципы в разработке распределенных систем, давайте теперь перейдем к более сложному моменту - масштабирование доступа к данным.

Самые простые веб-приложения, например, приложения стека LAMP, схожи с изображением на рисунке 1.5.


Рисунок 1.5: Простые веб-приложения


С ростом приложения возникают две основных сложности: масштабирование доступа к серверу приложений и к базе данных. В хорошо масштабируемом дизайне приложений веб-сервер или сервер приложений обычно минимизируется и часто воплощает архитектуру, не предусматривающую совместного разделения ресурсов. Это делает уровень сервера приложений системы горизонтально масштабируемым. В результате использовании такого дизайна тяжёлый труд сместится вниз по стеку к серверу базы данных и вспомогательным службам; именно на этом слое и вступают в игру настоящие проблемы масштабирования и производительности.

Остальная часть этой главы посвящена некоторым наиболее распространенным стратегиям и методам повышения производительности и обеспечения масштабируемости подобных типов служб путем предоставления быстрого доступа к данным.


Рисунок 1.6: Упрощенное веб-приложение


Большинство систем может быть упрощено до схемы на рисунке 1.6,
которая является хорошей отправной точкой для начала рассмотрения. Если у Вас есть много данных, можно предположить, что Вы хотите иметь к ним такой же легкий доступ и быстрый доступ, как к коробке с леденцами в верхнем ящике вашего стола. Хотя данное сравнение чрезмерно упрощено, оно указывает на две сложные проблемы: масштабируемость хранилища данных и быстрый доступ к данным.

Для рассмотрения данного раздела давайте предположим, что у Вас есть много терабайт (ТБ) данных, и Вы позволяете пользователям получать доступ к небольшим частям этих данных в произвольном порядке. (См. рисунок 1.7.)
Схожей задачей является определение местоположения файла изображения где-нибудь на файловом сервере в примере приложения хостинга изображений.


Рисунок 1.7: Доступ к определенным данным


Это особенно трудно, потому что загрузка терабайтов данных в память может быть очень накладной и непосредственно влияет на количество дисковых операций ввода-вывода. Скорость чтения с диска в несколько раз ниже скорости чтения из оперативной памяти - можно сказать, что доступ к памяти с так же быстр, как Чак Норрис, тогда как доступ к диску медленнее очереди в поликлинике. Эта разность в скорости особенно ощутима для больших наборов данных; в сухих цифрах доступ к памяти 6 раз быстрее, чем чтение с диска для последовательных операций чтения, и в 100,000 раз - для чтений в случайном порядке (см. "Патологии Больших Данных", http://queue.acm.org/detail.cfm?id=1563874). ). Кроме того, даже с уникальными идентификаторами, решение проблемы нахождения местонахождения небольшой порции данных может быть такой же трудной задачей, как и попытка не глядя вытащить последнюю конфету с шоколадной начинкой из коробки с сотней других конфет.

К счастью существует много подходов, которые можно применить для упрощения, из них четыре наиболее важных подхода - это использование кэшей, прокси, индексов и балансировщиков нагрузки. В оставшейся части этого раздела обсуждается то, как каждое из этих понятий может быть использовано для того, чтобы сделать доступ к данным намного быстрее.

Кэши


Кэширование дает выгоду за счет характерной черты базового принципа: недавно запрошенные данные вполне вероятно потребуются еще раз. Кэши используются почти на каждом уровне вычислений: аппаратные средства, операционные системы, веб-браузеры, веб-приложения и не только. Кэш походит на кратковременную память: ограниченный по объему, но более быстрый, чем исходный источник данных, и содержащий элементы, к которым недавно получали доступ. Кэши могут существовать на всех уровнях в архитектуре, но часто находятся на самом близком уровне к фронтэнду, где они реализованы, чтобы возвратить данные быстро без значительной нагрузки бэкэнда.

Каким же образом кэш может использоваться для ускорения доступа к данным в рамках нашего примера API? В этом случае существует несколько мест, подходящих размещения кэша. В качестве одного из возможных вариантов размещения можно выбрать узлы на уровне запроса, как показано на
рисунке 1.8.


Рисунок 1.8: Размещение кэша на узле уровня запроса


Размещение кэша непосредственно на узле уровня запроса позволяет локальное хранение данных ответа. Каждый раз, когда будет выполняться запрос к службе, узел быстро возвратит локальные, кэшированные данные, если таковые существуют. Если это не будет в кэше, то узел запроса запросит данные от диска. Кэш на одном узле уровня запроса мог также быть расположен как в памяти (которая очень быстра), так и на локальном диске узла (быстрее, чем попытка обращения к сетевому хранилищу).


Рисунок 1.9: Системы кэшей


Что происходит, когда вы распространяете кеширование на множество узлов? Как Вы видите в рисунке 1.9, если уровень запроса будет включать множество узлов, то вполне вероятно, что каждый узел будет и свой собственный кэш. Однако, если ваш балансировщик нагрузки в произвольном порядке распределит запросы между узлами, то тот же запрос перейдет к различным узлам, таким образом увеличивая неудачные обращения в кэш. Двумя способами преодоления этого препятствия являются глобальные и распределенные кэши.

Глобальный кэш


Смысл глобального кэша понятен из названия: все узлы используют одно единственное пространство кэша. В этом случае добавляется сервер или хранилище файлов некоторого вида, которые быстрее, чем Ваше исходное хранилище и, которые будут доступны для всех узлов уровня запроса. Каждый из узлов запроса запрашивает кэш таким же образом, как если бы он был локальным. Этот вид кэширующей схемы может вызвать некоторые затруднения, так как единственный кэш очень легко перегрузить, если число клиентов и запросов будет увеличиваться. В тоже время такая схема очень эффективна при определенной архитектуре (особенно связанной со специализированными аппаратными средствами, которые делают этот глобальный кэш очень быстрым, или у которых есть фиксированный набор данных, который должен кэшироваться).

Есть две стандартных формы глобальных кэшей, изображенных в схемах. На рисунке 1.10 изображена ситуация, когда кэшируемый ответ не найден в кэше, сам кэш становится ответственным за получение недостающей части данных от базового хранилища. На рисунке 1.11 проиллюстрирована обязанность узлов запроса получить любые данные, которые не найдены в кэше.


Рисунок 1.10: Глобальный кэш, где кэш ответственен за извлечение



Рисунок 1.11: Глобальный кэш, где узлы запроса ответственны за извлечение
Большинство приложений, усиливающих глобальные кэши, склонно использовать первый тип, где сам кэш управляет замещением и данными выборки, чтобы предотвратить лавинную рассылку запросов на те же данные от клиентов. Однако, есть некоторые случаи, где вторая реализация имеет больше смысла. Например, если кэш используется для очень больших файлов, низкий процент удачного обращения в кэш приведет к перегрузке кэша буфера неудачными обращениями в кэш; в этой ситуации это помогает иметь большой процент общего набора данных (или горячего набора данных) в кэше. Другой пример - архитектура, где файлы, хранящиеся в кэше, статичны и не должны быть удалены. (Это может произойти из-за основных эксплуатационных характеристик касательно такой задержки данных - возможно, определенные части данных должны оказаться очень быстрыми для больших наборов данных - когда логика приложения понимает стратегию замещения или горячие точки лучше, чем кэш.)

Распределенный кэш


В распределенном кэше (рисунок 1.12), каждый из его узлов владеет частью кэшированных данных, поэтому если холодильник в продуктовом магазине сравнить с кэшем, тогда распределенный кэш походит на хранение вашей еды в нескольких удобных для доступа местах - холодильнике, стойках и коробке для завтрака, что избавляет необходимости совершать путешествия на склад. Обычно кэш сегментирован при помощи непротиворечивой хеш-функции. Если узел запроса ищет определенную часть данных, он может быстро узнать, куда смотреть в распределенном кэше, чтобы определить, доступны ли эти данные. В этом случае каждый узел поддерживает маленькую часть кэша и сначала отправляет запрос данных другому узлу прежде, чем обращается к источнику. Поэтому, одно из преимуществ распределенного кэша - расширяемое пространство кэша, что достигается простым добавлением узлов к пулу обработки запросов.

Недостаток распределенного кэширования - работа в условиях недостающих узлов. Некоторые распределенные кэши обходят эту проблему, храня избыточные копии данных на множестве узлов; однако, можно представить, насколько быстро логическая структура такого кэша может сложной, особенно в условиях добавления или удаления узлов из уровня запроса. Стоит отметить - даже если узел исчезает, и часть кэша будет потеряна, последствия необязательно окажутся катастрофическими - запросы просто получат данные непосредственно от источника!


Рисунок 1.12: Распределенный кэш.


Большим преимуществом кэшей является увеличение скорости работы системы (безусловно, только при правильной реализации!) Выбранная методология позволяет ускорить этот процесс для еще большего количества запросов. Однако, использование кэширования предполагает определенные затраты на поддержание дополнительного пространства обычно дорогостоящей памяти. Кэши замечательно подходят не только для общего увеличения производительности системы, но и обеспечения ее функциональность при нагрузке такого высокого уровня, которая в обычной ситуации привела бы к полному отказу в обслуживании.

Одним из популярных примеров кэша с открытым исходным кодом можно назвать Memcached (), который может работать как локальным, так и распределенным кэшем); кроме того, есть много других вариантов (включая специфичные для определенного языка или платформы).

Memcached используется во многих больших веб-сайтах, и даже при том, что он может быть очень мощным, представляет собой просто хранилище типа ключ-значение в оперативной памяти, оптимизированного для произвольного хранения данных и быстрых поисков (O(1)).

Facebook использует несколько различных типов кэширования, чтобы добиться высокой производительности своего сайта (см., "Facebook: кэширование и производительность"). Они используют $GLOBALS и APC, кэширующие на уровне языка (представленные в PHP за счет вызова функции), который способствует ускорению промежуточных вызовов функции и получению результатов. (Большинство языков оснащены этими типами библиотек для улучшения производительность веб-страницы, и они почти всегда должны использоваться.) Кроме того Facebook использует глобальный кэш, который распределен на множество серверов (см. "Масштабирование memcached в Facebook"), таким образом, что один вызов функции, получающий доступ к кэшу, мог параллельно выполнить множество запросов для данных, хранящихся на различных серверах Memcached. Такой подход позволяет добиться намного более высокой производительности и пропускной способности для данных профиля пользователя, и создать централизованную архитектуру обновления данных. Это важно, так как, при наличии тысяч серверов, функции аннулирования и поддержания непротиворечивости кэша могут вызывать затруднение.

Далее речь пойдет об алгоритме действий в случае отсутствия данных в кэше.

На главную -> MyLDP -> Тематический каталог ->

Масштабируемая Веб-архитектура и распределенные системы

Глава 1 из книги "Архитектура приложений с открытым исходным кодом", том 2.
Оригинал: "Scalable Web Architecture and Distributed Systems", глава из книги "The Architecture of Open Source Applications" том 2.
Автор: Кейт Мэтсудейра,
Перевод: © jedi-to-be. Коррекция: Anastasiaf15, sunshine_lass, Amaliya, fireball, Goudron.
Перевод впервые был опубликован на сайте Хабрахабр

Прокси


На базовом уровне прокси-сервер - промежуточная часть аппаратных средств/программного обеспечения, которые получают запросы от клиентов и передают их к серверам источника бэкэнда. Как правило, прокси используются, чтобы фильтровать запросы, протоколировать запросы, или иногда преобразовывать запросы (добавляя/удаляя заголовки, шифруя/дешифруя или сжимая).


Рисунок 1.13 Прокси-сервер


Прокси также очень полезны при координировании запросов, поступающих от большого количества серверов, что дает возможность оптимизировать трафик запроса в масштабе всей системы. Один их способов использования прокси для ускорения доступа к данным заключается в объединении одинаковых или схожих запросов и передачи единого ответа клиентам запроса. Этот термин получил название сжатое перенаправление (collapsed forwarding).

Представим, что с нескольких узлов поступают запросы на одинаковые данные (назовем их littleB), но в кэше часть этих данных отсутствует. Если этот запрос направляется через прокси, то все запросы могут быть объединены в один, и в результате этой оптимизации littleB будет считан с диска только один раз. (См. рисунок 1.14) В этом случае придется немного пожертвовать скоростью, поскольку процесс обработки запросов и их объединения привести к несколько более длительным задержкам. Однако, при высокой нагрузке это напротив приведет к улучшению производительности, особенно в случае многократных запросов одинаковых данных. Стратегия функционирования прокси аналогична кэшу, но вместо хранения данных, он оптимизирует запросы или вызовы документам.

В LAN-прокси, например, клиенты не нуждаются в своем собственном IP-адресе, для соединения с Интернетом. Прокси объединяет запросы от клиентов на одинаковый контент. Однако это порождает двусмысленность, так как многие прокси являются также и кэшами (поскольку являются логичным местом для размещения кэша), но не все кэши работают как прокси.


Рисунок 1.14: Использование прокси-сервера для комбинирования запросов


Еще одним отличный способ использования прокси состоит не просто в объединении запросов одинаковых данных, но также и частей данных, которые находятся пространственно близко друг к другу в хранилище источника (последовательно на диске). Использование такой стратегии максимизирует локальность данных для запросов, что может привести к сокращению задержки запроса. Например, если набор узлов запрашивает части B: часть-B1, часть-B2, и т.д., мы можем настроить наш прокси, чтобы он распознавал пространственное местоположение отдельных запросов, комбинируя их в единственный запрос и возвращаясь только bigB, значительно минимизируя чтения из источника данных. (См. рисунок 1.15) В случае доступа к целым терабайтам данных в произвольном порядке время реализации запроса может сильно отличаться. Так как прокси могут, по существу, сгруппировать несколько запросов в один, они особенно полезны в ситуациях с высокой нагрузкой или ограниченными возможностями кэширования.


Рисунок 1.15: Использование прокси для комбинирования запросов на данные, находящихся пространственно близко друг к другу


Стоит отметить, что вы можете использовать прокси и кэши вместе, но обычно лучше помещать кэш перед прокси по той же причине, по которой лучше позволять более быстрым бегунам стартовать в марафоне с большим количеством участников. Это вызвано тем, что кэш использует данные из памяти, что очень быстро, и это не противоречит многократным запросам на тот же результат. Но если бы кэш был расположен с другой стороны прокси-сервера, то возникла бы дополнительная задержка для каждого запроса перед кэшем, что могло бы снизить производительность.

Если вы рассматриваете добавление прокси в ваши системы, то у вас есть много вариантов для выбора;
Squid и
Varnish прошли испытания временем и широко используются во многих производительных веб-сайтах. Эти решения для прокси предлагают множество вариантов оптимизации, чтобы выжать максимум из клиент-серверного обмена данными. Установка одного из них в режиме реверсивного прокси (описан ниже в разделе о балансирующей нагрузке) на уровне веб-сервера может значительно улучшить производительность веб-сервера, уменьшая объем работы для обработки входящих клиентских запросов.

Индексы


Использование индекса для получения быстрого доступа к вашим данным - известная стратегия для того, чтобы эффективно оптимизировать доступ к данным. Наиболее широкое применение индексирование находит в базах данных. Индекс делает взаимные уступки, используя издержки объемов хранения данных и снижая скорости операций (так как вы должны одновременно и записывать данные, и обновлять индекс), позволяя получить выигрыш в виде более быстрых операций .

Вы можете также применить эту концепцию к более крупным хранилищам данных, точно так же, как и к реляционным наборам данных. Хитрость с индексами заключается в четком понимании того, как пользователи получают доступ к вашим данным. В случае если объемы наборов данных измеряются многими терабайтами, а полезной информации в них совсем немного (например, 1 Кбайт), использование индексов является необходимостью для оптимизации доступа к данным. Нахождение малой по размеру полезной информации в таком большом наборе данных может быть реальной проблемой, так как вы точно не сможете последовательно перебрать такое большое количество данных за любое разумное время. Кроме того, весьма вероятно, что такой большой набор данных распределен между несколькими (или многими!) физическими устройствами, и это означает, что вам необходимо каким-то образом найти правильное физическое местоположение нужных данных. Индексы - лучший способ сделать это.


Рисунок 1.16: Индексы


Индекс может использоваться как оглавление, которое направляет вас к местоположению ваших данных. К примеру, скажем, вы ищете порцию данных, часть ©2 секции "B" - как вы узнаете, где ее найти? Если у вас есть индекс, отсортированный по типу данных - назовем данные "A", "B", "C" - он укажет вам расположение данных "B" в источнике. Тогда вы просто должны найти это расположение и считать ту часть "B", которая вам нужна. (См. рисунок 1.16)

Данные индексы часто хранятся в памяти или где-нибудь очень локально по отношению к входящему запросу клиента. Berkeley DB (BDB) и древовидные структуры данных, которые обычно используются, чтобы хранить данные в упорядоченных списках, идеально подходят для доступа с индексом.

Часто имеется много уровней индексов, которые служат картой, перемещая вас от одного местоположения к другому, и т.д., до тех пор пока вы не получите ту часть данных, которая вам необходима. (См. рисунок 1.17)


Рисунок 1.17: Многоуровневые индексы


Индексы могут также использоваться для создания нескольких других представлений тех же данных. Для больших наборов данных это - отличный способ определить различные фильтры и виды, не прибегая к созданию многих дополнительных копий данных.

Например, предположим, что система хостинга изображений, упомянутая выше, на самом деле размещает изображения книжных страниц, и сервис обеспечивает возможность клиентских запросов по тексту в этих изображениях, ища все текстовое содержимое по заданной теме также, как поисковые системы позволяют вам искать по HTML-содержимому. В этом случае все эти книжные изображения используют очень много серверов для хранения файлов, и нахождение одной страницы для представления пользователю может быть достаточно сложным. Изначально обратные индексы для запроса произвольных слов и наборов слов должны быть легкодоступными; тогда существует задача перемещения к точной странице и месту в этой книге и извлечения правильного изображения для результатов поиска. Таким образом, в этом случае инвертированный индекс отобразился бы на местоположении (таком как книга B), и затем B может содержать индекс со всеми словами, местоположениями и числом возникновений в каждой части.

Инвертированный индекс, который может отобразить Index1 в схеме выше, будет выглядеть примерно так: каждое слово или набор слов служат индексом для тех книг, которые их содержат.



будучи удивительной книга B, Книга C, Книга D
всегда Книга C, Книга F
верьте Книга B



Промежуточный индекс будет выглядеть похоже, но будет содержать только слова, местоположение и информацию для книги B. Такая содержащая несколько уровней архитектура позволяет каждому из индексов занимать меньше места, чем, если бы вся эта информация была сохранена в один большой инвертированный индекс.

И это ключевой момент в крупномасштабных системах, потому что даже будучи сжатыми, эти индексы могут быть довольно большими и затратными для хранения. Предположим, что у нас есть много книг со всего мира в этой системе, - 100,000,000 (см. запись блога "Внутри Google Books")- и что каждая книга состоит только из 10 страниц (в целях упрощения расчетов) с 250 словами на одной странице: это суммарно дает нам 250 миллиардов слов. Если мы принимаем среднее число символов в слове за 5, и каждый символ закодируем 8 битами (или 1 байтом, даже при том, что некоторые символы на самом деле занимают 2 байта), потратив, таким образом, по 5 байтов на слово, то индекс, содержащий каждое слово только один раз, потребует хранилище емкостью более 1 терабайта. Таким образом, вы видите, что индексы, в которых есть еще и другая информация, такая, как наборы слов, местоположение данных и количества употреблений, могут расти в объемах очень быстро.



Создание таких промежуточных индексов и представление данных меньшими порциями делают проблему более простой в решении. Данные могут быть распределены на множестве серверов и в то же время быть быстродоступны. Индексы - краеугольный камень информационного поиска и база для сегодняшних современных поисковых систем. Конечно, этот раздел лишь в общем касается темы индексирования, и проведено множество исследований о том, как сделать индексы меньше, быстрее, содержащими больше информации (например, релевантность), и беспрепятственно обновляемыми. (Существуют некоторые проблемы с управляемостью конкурирующими условиями, а также с числом обновлений, требуемых для добавления новых данных или изменения существующих данных, особенно в случае, когда вовлечены релевантность или оценка).

Очень важна возможность быстро и легко найти ваши данные, и индексы - самый простой и эффективный инструмент для достижения этой цели.

1.4. Заключение


Разработка эффективных систем с быстрым доступом к большому количеству данных является очень интересной темой, и существует еще значительное число хороших инструментов, которые позволяют адаптировать все виды новых приложений. Эта глава коснулась всего лишь нескольких примеров, но в реальности их гораздо больше - и создание новых инноваций в этой области будет только продолжаться.

2.1. Рассмотрите N способов до момента подготовки релиза

Процесс подготовки релиза от разработки кода до команды "отправка на сборку"
Рисунок 2.1: Процесс подготовки релиза от разработки кода до команды "отправка на сборку"

В момент начала реализации проекта по усовершенствованию процесса выпуска релизов программных продуктов организации Mozilla, мы использовали в качестве исходного условия тот факт, что чем более популярным станет веб-браузер Firefox, тем у нас будет больше пользователей, а веб-браузер станет более привлекательной целью для злоумышленников, ищущих уязвимости системы безопасности для последующей эксплуатации. Также, чем более популярным станет веб-браузер Firefox, тем тем большее количество пользователей нам придется защищать от вновь и вновь появляющихся уязвимостей системы безопасности, поэтому наиболее важным аспектом становится возможность доставки исправлений для системы безопасности так быстро, как это возможно. Для такой ситуации у нас даже есть термин: "chemspill"-релиз (сокращение от "chemical spill" - "разлив химикатов"). Вместо редких выпусков chemspill-релизов в промежутках времени между регулярно планируемыми выпусками релизов, мы решили проводить планирование с учетом того, что каждый релиз может быть chemspill-релизом и спроектировали систему автоматизации в соответствии этой моделью планирования.

Данный подход имеет три важных последствия:

  1. Мы подводим итоги работы после выпуска каждого релиза и и ищем области, в которых работа могла быть выполнена в следующий раз более плавно, просто и быстро. Если все это возможно, мы ищем и незамедлительно до выпуска следующего релиза исправляем как минимум одну недоработку, причем не важно насколько эта недоработка значительна. Постоянное усовершенствование нашей системы автоматизации выпуска релизов подразумевает то, что мы всегда ищем новые пути для того, чтобы снизить степень вовлеченности людей в процесс и в то же время улучшить устойчивость системы и сократить время выполнения работы. Большие усилия были затрачены на то, чтобы сделать наши инструменты безопасными, таким образом "редкие" события, например, нарушения работы сети, недостаток дискового пространства или опечатки, допущенные живыми людьми обнаруживаются и обрабатываются на таких ранних этапах, как это возможно. Несмотря на то, что наша система на данный момент достаточно быстра для выпуска обычных релизов, не являющихся chemspill-релизами, мы хотим сократить риск появления в будущем релизе любой ошибки, допущенной человеком. Это особенно актуально для chemspill-релизов.
  2. При подготовке chemspill-релиза мы исходим из того, что чем устойчивее система автоматизации, тем меньше подвержены стрессу люди из группы подготовки релизов. Мы придерживаемся идеи, заключающейся в выполнении работы так быстро, как это возможно в спокойной обстановке и мы создали инструменты для выполнения работы в настолько безопасном и надежном режиме, насколько позволяют наши знания. Меньшая подверженность стрессу подразумевает более спокойную и тщательную работу в ходе выполнения хорошо отрепетированного процесса, что в свою очередь помогает избежать сложностей при выпусках chemspill-релизов.
  3. Мы создали процесс "отправки на сборку" в масштабах организации Mozilla. При подготовке обычного (не chemspill) релиза каждому участнику процесса разработки может быть предоставлена возможность обзора одних и тех же отсортированных цепочек описаний ошибок, точного установления момента применения последнего исправления и удачного тестирования, а также достижения договоренности о том, когда должны быть начаты сборки. Однако, в случае chemspill-релиза, когда минуты имеют значение, отслеживание всех особенностей ошибки вместе с чтением всех последующих подтверждений наличия ошибки и предложенных исправлений через некоторое время становится очень сложным занятием. Для снижения сложности и риска совершения ошибок организация Mozilla наняла человека, который в течение полного рабочего дня отслеживает готовность кода для "отправки на сборку". Изменение процессов в ходе выпуска chemspill-релиза рискованно, поэтому для уверенности в том, что каждый знаком с процессом выпуска релиза в ситуации, когда минуты имеют значение, мы используем один и тот же процесс подготовки к выпуску chemspill- и обычных релизов.

Полное распределение по времени этапов процесса подготовки chemspill-релиза, используемого в качестве примера
Рисунок 2.2: Полное распределение по времени этапов процесса подготовки chemspill-релиза, используемого в качестве примера

2.2. "Отправка на сборку"

Кто может быть источником команды "отправка на сборку"?

Перед началом подготовки релиза назначается один человек, который будет нести ответственность за координацию всего процесса выпуска релиза. Этот человек должен посещать собрания, на которых координируется работа, понимать контекст всей выполненной работы, справедливо рассуждать о приоритетах ошибок, подтверждать возможность внесения запоздавших изменений, а также принимать строгие решения, заключающиеся в отклонении предложений. К тому же, непосредственно в день релиза этот человек является центром взаимодействия различных групп (разработчиков, контроля качества, подготовки релизов, разработчиков вебсайта, по связям со СМИ, по маркетингу, и.т.д.).

Различные компании используют различные термины для обозначения этой должности. Среди некоторых услышанных нами терминов есть такие, как менеджер релизов (Release Manager), инженер релизов (Release Engineer), менеджер программ (Program Manager), менеджер проекта (Project Manager), менеджер продукта (Product Manager), руководитель выпуска продукта (Product Czar), руководитель релиза (Release Driver). В данной главе мы будем использовать термин "координатор релиза" ("Release Coordinator"), так как мы считаем, что он наиболее точно отражает соответствующую роль в описанном выше процессе. Важным моментом является то, что роль и конечные полномочия выступающего в данной роли человека должны быть четко поняты всеми участниками процесса перед началом подготовки релиза вне зависимости от любых предшествующих периодов совместной работы над каким-либо проектом. В напряженной обстановке, создающейся в день релиза, важно, чтобы каждый знал, что от этого человека следует ожидать принятия решений, связанных с координацией процесса, придерживаться их и доверять им.

Координатор релиза является единственным человеком вне группы подготовки релизов, который может отправлять электронные письма для выполнения "остановки сборок" в том случае, если обнаруживается проблема, не позволяющая распространять собранный релиз. Любые отчеты о подозрительных проблемах, которые также могут препятствовать распространению релиза, перенаправляются координатору релиза, который впоследствии исследует их, примет окончательное решение о их принятии или отклонении и своевременно сообщит об этом решении всем заинтересованным сторонам. В напряженной обстановке момента сборки релиза нам всем следует ожидать принятия этим человеком связанных с координацией процесса сборки решений, придерживаться их и доверять им.

Как передать команду "отправка на сборку"?

Ранние эксперименты с передачей команды "отправка на сборку" посредством IRC-каналов или устной передачей этой же команды по телефону приводили к непониманию со стороны участников команды, которое порой порождало проблемы в процессе выпуска релиза. Исходя из этого, на данный момент мы требуем передачи сигнала "отправка на сборку" для каждого релиза с использованием электронной почты путем отправки сообщения в список рассылки, на который подписаны участники всех групп, занимающиеся выпуском релизов. Тема электронного сообщения должна включать фразу "go to build" и четко указанное название продукта с номером версии, например:

go to build Firefox 6.0.1

Аналогично, в том случае, если в релизе обнаруживается проблема, координатор релиза передаст команду "остановить все сборки" ("all stop") в форме электронного сообщения с новой темой, отправленного в тот же список рассылки. Мы посчитали неэффективной отправку всего лишь ответа на последнее относящееся к релизу электронное сообщение; в некоторых клиентах электронной почты темы обсуждений оформляются таким образом, что пользователи не заметят сообщение с командой "остановить все сборки", так как оно будет находиться гораздо ниже в несвязанной теме.

Какая информация содержится в электронном сообщении с командой "отправка на сборку"?

  1. Указание на именно тот исходный код, который должен использоваться для релиза; в идеальном случае это указание должно быть строкой URL, указывающей на определенное изменение в репозитории исходного кода, на основе которого будут формироваться сборки для релиза.
    1. Инструкции, аналогичные "использованию новейшего исходного кода" неприемлемы ни в каком случае; при подготовке одного из релизов в момент после передачи команды "отправка на сборку" в сообщении электронной почты и до непосредственного начала процесса сборки разработчик из лучших побуждений разместил неподтвержденное изменение исходного кода в не предназначенной для изменения ветви репозитория. Это незапланированное изменение вошло в релиз. Благодаря нашей внимательности ошибка была выявлена до момента предоставления публичного доступа к релизу, тем не менее, нам пришлось задержать релиз из-за затрат времени на полную остановку процесса сборки и на выполнение повторной сборки.
    2. При использовании подобной CVS системы контроля версий, работающей на основе меток времени, следует явно указывать точное время; указывайте время с точностью до секунд и добавляйте к нему указание часового пояса. Во время выпуска одного из релизов в то время, когда для хранения кода Firefox все еще использовалась система CVS, координатор релиза указал граничное время изменений кода для сборки, но не указал часовой пояс. Со временем группа подготовки релизов обнаружила отсутствие информации о часовом поясе, но координатор релиза уже спал. Участники группы подготовки релизов правильно догадались, что должно было использоваться местное время (для штата Калифорния), но из-за ночной путаницы и использования часового пояса PDT вместо PST мы потеряли последнее исправление критической ошибки. Эта недоработка была выявлена группой контроля качества продукта перед предоставлением доступа к сборке широкому кругу пользователей и нам также пришлось останавливать сборку и начинать ее сначала с использованием корректного граничного времени изменений кода.
  2. Четкое обоснование срочности данного релиза. Хотя эта информация и кажется очевидной, она важна при работе в некоторых нестандартных ситуациях, поэтому ниже приведено краткое описание типов релизов:
    1. Некоторые релизы являются "обычными" релизами и могут подготавливаться в обычное рабочее время. Это ранее запланированные релизы, которые выпускаются в оговоренное время и не требуют выполнения срочной работы. Конечно же, все сборки программных продуктов для релизов должны формироваться своевременно, но от людей, занимающихся подготовкой релизов, не требуется работы в течение нескольких ночей и приложения всех сил для выпуска обычного релиза. Вместо этого мы заранее правильно планируем выпуск таких релизов, поэтому весь процесс подготовки релиза проходит в соответствии с ранее составленным графиком и люди работают над релизом в обычное рабочее время. Такой подход позволяет не загружать людей излишней работой, причем они смогут работать и в случае появления необходимости выполнения срочной незапланированной работы.
    2. Некоторые релизы являются chemspill-релизами, которые должны срочно подготавливаться и выпускаться в условиях, когда каждая минута на счету. Эти релизы обычно выпускаются в случае необходимости исправления ошибки, используемой опубликованным эксплоитом или в случае необходимости исправления недавно выявленной ошибки, заключающейся в аварийном завершении процесса и затрагивающей большую часть нашей пользовательской базы. Chemspill-релизы должны создаваться так быстро, как это возможно и обычно при их выпуске не используется предварительное планирование.
    3. Некоторые релизы могут превращаться из обычных релизов в chemspill-релизы и наоборот из chemspill-релизов в обычные релизы. Например, в том случае, если исправление безопасности из обычного релиза было непредумышленно опубликовано, обычный релиз превращается в chemspill-релиз. В том случае, если маркетинговое требование, заключающиеся в выпуске "предварительного специального релиза" для демонстрации на проходящей конференции приводит к задержке выпуска релиза по маркетинговым соображениям, релиз превращается из chemspill-релиза в обычный релиз.
    4. По отношению к срочности некоторых релизов разные люди могут иметь разные мнения, зависящие от их взгляда на исправления, поставляемые в рамках релиза.

В обязанности координатора релиза входит взвешивание всех фактов и мнений и вынесение решения о срочности релиза, а также взаимодействие со всеми группами и сообщение о принятом решении. В случае получения новой информации координатор релиза пересматривает решение и затем снова сообщает о новой установленной срочности релиза участникам тех же групп. В том случае, если участники некоторых групп будут считать релиз chemspill-релизом и в то же время участники других групп будут считать тот же релиз обычным релизом, взаимодействие групп может быть нарушено.

Наконец, эти сообщения электронной почты также становятся очень полезными в случае необходимости измерения времени, прошедшего с момента выпуска релиза. Хотя точность измерения времени соответствует точности обычных часов, эта информация очень полезна при установлении той области, в которой в следующий раз нужно приложить наши силы для ускорения процесса. Как говорит древняя поговорка, перед тем, как вы сможете улучшить что-либо, у вас должна быть возможность измерить это.

В течение цикла бета-тестирования браузера Firefox мы также еженедельно выпускаем релизы на основе кода из нашего репозитория mozilla-beta. Каждый из этих бета-релизов проходит обычные стадии подготовки в нашей полностью автоматизированной системе и рассматривается практически идентично нашим обычным финальным релизам. Для уменьшения количества неприятных сюрпризов в процессе подготовки релиза мы пытаемся не вносить новых не протестированных изменений в систему автоматизации выпуска релизов или инфраструктуру до момента начала сборки финального релиза.

2.3. Использование тэгов, сборка и архивы с исходным кодом

Автоматизированная расстановка тэгов
Рисунок 2.3: Автоматизированная расстановка тэгов

При подготовке к началу процесса автоматизации системы мы приступили к использованию сценария release_sanity.py, который был разработан интерном, проходящим летнюю стажировку в группе подготовки релизов. Этот сценарий на языке Python помогает подготавливающему релиз участнику группы, производя двойную проверку соответствия конфигурации релиза конфигурациям инструментов и репозиториев. Он также устанавливает выбранные ревизии исходного кода для релиза из ветки mozilla-release и все поставляемые в рамках этого релиза (человеческие) языки, на основе которых будут сгенерированы сборки для выпуска релиза и пересборки с поддержкой определенных языков.

Сценарий принимает файлы конфигурации системы непрерывной интеграции buildbot для любых конфигураций релиза, которые будут использоваться (таких, как конфигурации для сборки версий для настольных компьютеров и мобильных устройств), указание на ветвь в репозитории исходного кода для поиска (т.е., mozilla-release), номер сборки и версию, а также названия продуктов, которые будут собраны (такие, как "fennec" или "firefox"). Его выполнение завершится неудачей в том случае, если конфигурации репозиториев кода для формирования релизов не совпадают с заданной конфигурацией, если наборы изменений в репозитории файлов локализации не совпадают с поставляемыми нами наборами изменений файлов локализации или в том случае, когда версия и номер сборки не совпадают с переданными нашим инструментам сборки параметрами в форме тэга на основе номеров продукта, версии и сборки. В том случае, если все проверки из сценария завершатся успешно, он произведет повторную конфигурацию мастер-сервера системы buildbot, на котором выполняется сценарий и который будет управлять системами сборки релизов, после чего генерирует команду "отправка изменений" ("send change"), которая запускает автоматизированный процесс подготовки сборки.

После того, как участник группы подготовки релиза запускает системы для сборки, первым автоматически выполняемым шагом в процессе подготовки релиза Firefox является установка тэга во всех используемых репозиториях исходного кода для записи номера ревизии данных из репозиториев исходного кода, языков и связанных инструментов, которые были использованы для подготовки данной версии и сборки релиз-кандидата. Эти тэги позволяют нам отслеживать историю версий и номеров сборок релизов Firefox и Fennec (мобильной версии Firefox) в рамках наших репозиториев для релизов. Для релизов Firefox примером установленного тэга может служить FIREFOX_10_0_RELEASE FIREFOX_10_0_BUILD1 FENNEC_10_0_RELEASE FENNEC_10_)_BUILD1.

При подготовке каждого отдельного релиза Firefox используется код в среднем из 85 репозиториев системы контроля версий, которые хранят такие данные, как код программного продукта, строки локализации, код системы автоматизации для выпуска релизов, а также код вспомогательных утилит. Присвоение тэгов коду из всех этих репозиториев критически важно, так как необходима гарантия того, что на следующих этапах работы системы автоматизации выпуска релизов будут выполняться действия с тем же набором ревизий данных.

Такой подход также имеет ряд других преимуществ: команды разработчиков дистрибутивов Linux и другие участники процесса разработки могут самостоятельно повторять процесс сборки, используя тот же самый код и инструменты, что и в случае подготовки официальных сборок, а также производится запись ревизий исходного кода продукта и инструментов, используемых в ходе подготовки каждого релиза, на основе данных которой в будущем возможно сравнение изменений, осуществленных между релизами.

Как только во всех репозиториях выделены ветви и созданы тэги, группа зависимых сборочных систем автоматически начинает работу: выделяется по одной сборочной системе для каждой платформы, а также для подготовки архива исходного кода, включающего весь исходный код, используемый в релизе. Архив исходного кода и собранные установщики загружаются в директорию для релизов по мере доступности. Это позволяет любому человеку ознакомиться с тем, какой именно код был использован для формирования релиза, а также появляется возможность использовать архив исходного кода для повторной сборки в том случае, когда появится такая необходимость (например, в случае какого-либо сбоя нашей системы контроля версий). Для создания архива исходного кода Firefox нам иногда приходится импортировать код из репозитория, предназначенного для подготовки более ранних версий. Например, в случае выпуска бета-релиза происходит извлечение подписанной ревизии из репозитория Mozilla-Aurora (нашего репозитория исходного кода, отличающегося большей стабильностью, чем репозиторий для подготовки ночных сборок) для версии Firefox 10.0b1.

В случае выпуска релиза происходит перемещение подтвержденных изменений из репозитория Mozilla-Beta (практически с тем же кодом, который использовался для подготовки версии 10.0b6) в репозиторий Mozilla-Release. Позднее ветвь для выпуска релиза создается в форме именованной ветви, родительским набором изменений которой является подписанная ревизия, предназначенная для "отправки на сборку" и предоставленная координатором релиза. Ветвь для выпуска релиза может быть использована для выполнения специфичных для релиза модификаций исходного кода, таких, как увеличение номеров версий или завершение формирования списка локализаций, которые будут собраны. В будущем при обнаружения критической проблемы безопасности и необходимости ее немедленного исправления, на основе этой ветви для выпуска релиза может быт сформирован содержащий минимальное количество изменений для исправления уязвимости chemspill-релиз, после чего будет сгенерирована и выпущена новая версия Firefox.

Когда нам понадобится произвести второй цикл сборок определенного релиза под названием buildN, мы используем эти ветви для выпуска релиза с целью получения того же кода, который был подписан для "отправки на сборку", при этом любые изменения в коде релиза будут размещаться именно в этой ветви. Автоматизированный процесс сборки начинается снова с создания тэгов для нового набора изменений в ветви для подготовки релиза. В ходе выполнения нашего процесса присвоения тэгов производится большое количество операций с локальными и удаленными репозиториями исходного кода системы контроля версий Mercurial. Для упрощения некоторых из наиболее часто выполняемых операций мы разработали несколько вспомогательных инструментов: retry.py и hgtool.py. Сценарий retry.py является простой оболочкой, которая получает переданную команду и выполняет ее, пытаясь в течение нескольких раз повторить выполнение в случае неудачи. Он также следит за возникновением исключительных условий при выводе результатов выполнения команд и повторно выполняет команды, либо выводит сообщения в подобных случаях. Мы посчитали удобным решением выполнение при посредничестве сценария retry.py большей части команд, которые могут завершиться неудачей из-за внешних зависимостей.

При присваивании тэгов операции системы контроля версий Mercurial могут завершиться неудачей в случае временной неработоспособности сети, неработоспособности веб-серверов или временной чрезмерной нагрузки на используемый сервер системы Mercurial. Возможность автоматического повтора этих операций и продолжения выполнения работы сохраняет очень много нашего времени, так как нам не приходится вручную восстанавливать и удалять результаты неудавшейся попытки сборки, после чего снова запускать автоматизированную систему подготовки релизов.

Сценарий hgtool.py является утилитой, инкапсулирующей некоторые стандартные операторы системы Mercurial, такие, как операторы клонирования, операторы извлечения и обновления данных репозитория, при этом позволяющей выполнять их в ходе однократного запуска. Он также добавляет поддержку расширения для разделения данных системы Mercurial, которое широко используется нами для предотвращения появления нескольких клонированных копий репозиториев в различных директориях одной и той же машины. Добавление поддержки разделяемых локальных репозиториев значительно ускорило наш процесс присвоения тэгов, так как большинство полностью клонированных репозториев с кодом продукта и данными локализации могли быть исключены из процесса. Важной мотивацией для разработки подобных инструментов является предоставление возможности тестирования системы автоматизации настолько, насколько это возможно. Так как такие инструменты, как hgtool.py являются небольшими утилитами для выполнения единственной задачи, созданными на основе многократно используемых библиотек, их гораздо проще тестировать в изолированном окружении.

На сегодняшний день наш процесс присвоения тэгов состоит из двух параллельныо выполняемых процессов: первым является процесс присвоения тэгов версии Firefox для настольных компьютеров, который длится около 20 минут, так как предусматривает присвоение тэгов данным в более чем 80 репозиториях локализаций, а вторым является процесс присвоения тэгов данным мобильной версии Firefox, который занимает около 10 минут, так как для мобильных релизов на данный момент доступно меньшее количество локализаций. В будущем мы хотели бы ускорить наш процесс автоматической подготовки релизов, поэтому мы хотим присваивать тэги данным во всех различных репозиториях в параллельном режиме. Начальная сборка программных продуктов может запускаться тогда, когда данным в репозиториях с кодом продукта и кодом требуемых для сборки инструментов будут присвоены тэги без необходимости ожидания присваивания тэгов данным во всех репозиториях локализаций. В момент завершения этих сборок данным в остальных репозиториях будут также присвоены тэги, поэтому пакеты локализаций смогут быть подготовлены и следующие шаги процесса сборки смогут завершиться. Мы ожидаем, что эти мероприятия помогут уменьшить общее время подготовки сборок примерно на 15 минут.

2.4. Повторно упакованные сборки с локализацией и партнерские повторно упакованные сборки

Как только сборки для настольных компьютеров генерируются и загружаются на сервер ftp.mozilla.org, наша автоматизированная система подготовки релизов переходит к выполнению работ по созданию повторно упакованных сборок с локализациями. "Повторно упакованная сборка с локализацией" формируется на основе оригинальной сборки (которая содержит локализацию en-US) путем ее распаковки, замены строк локализации en-US на строки другой локализации, которую мы будем распространять в рамках данного релиза, с последующей повторной упаковкой всех извлеченных файлов (именно поэтому мы и называем эти сборки повторно упакованными). Мы повторяем эту процедуру для каждой локализации, поставляемой в рамках релиза. Изначально мы осуществляли все повторные упаковки файлов последовательно. Однако, по мере того, как мы добавляли дополнительные локализации, этот процесс все больше затягивался и нам приходилось начинать его выполнение с начала в случае возникновения ошибки на промежуточном этапе.

Повторная упаковка файлов Firefox для каждой из локализаций
Рисунок 2.4: Повторная упаковка файлов Firefox для каждой из локализаций

В настоящее время мы разделили весь процесс повторной упаковки на шесть отдельных процессов, которые выполняются одновременно на шести отдельных машинах. Этот подход позволил сократить время работы практически в шесть раз. Также он позволил отменять повторную упаковку только ограниченного количества сборок в случае возникновения ошибки при упаковке без необходимости отмены всех упаковок. (Мы можем разделить задачи по выполнению повторных упаковок сборок даже на еще большее количество меньших одновременно обрабатываемых групп, но мы считаем, что такой подход подразумевает задействование слишком большого количества доступных машин, которые выполняют другие не связанные с повторной упаковкой сборок задачи, инициируемые разработчиками для обслуживания нашей системы непрерывной интеграции.)

Процесс для мобильной версии (предназначенной для работы под управлением Android) значительно отличается от описанного выше, так как мы выпускаем только два установщика: установщик англоязычной версии и установщик многоязычной версии со встроенной в установщик поддержкой множества множества языков вместо предоставления сборки для каждой локализации. Размер этой многоязычной версии имеет значение, особенно в случае использования медленных соединений и мобильных устройств с ограниченными ресурсами. Наш план на будущее заключается в предоставлении поддержки других языков посредством ресурса addons.mozilla.org по запросу.

На Рисунке 2.4 вы можете увидеть, что на данный момент мы используем три различных источника информации о доступных локализациях: файлы shipped_locales, l10_changesets и l10-changesets_mobile-release.json. (Планируется перемещение данных из всех трех источников в один файл в унифицированном формате JSON.) Эти файлы содержат информацию о различных локализациях, находящихся в нашем распоряжении, а также о исключениях для определенных платформ. В частности, при использовании заданной локализации нам необходимо обладать информацией о том, какая ревизия данных из репозитория должна быть использована для подготовки заданного релиза, а также нам необходимо обладать информацией о том, может ли локализация использоваться на всех поддерживаемых нами платформах (например, все данные японских локализаций для платформы Mac извлекаются из отдельного репозитория). Два из трех описанных выше файлов используются при подготовке релизов для настольных компьютеров и один файл - при подготовке релизов для мобильных устройств (в этом файле формата JSON содержится как список платформ, так и описание наборов изменений данных репозиториев).

Кто же принимает решение о том, какие языки мы включаем в комплект поставки релизов? Во-первых, создатели локализаций самостоятельно предлагают свои определенные наборы изменений локализаций для включения в состав заданного релиза. Предложенный набор изменений проверяется участниками команды локализации организации Mozilla и размещается на веб-ресурсе со списком наборов изменений, необходимых для поддержки каждого из языков. Координатор релиза проверяет размещенную на этом ресурсе информацию перед передачей сообщения электронной почты с командой "отправка на сборку". В день выпуска релиза мы извлекаем этот список наборов изменений и производим повторную упаковку сборок в соответствии с ним.

Наряду с повторными упаковками сборок с локализациями мы также генерируем партнерские повторно упакованные сборки. Это специально измененные сборки для различных партнеров, которые желают внести изменения в пользовательские качества программного продукта для удовлетворения запросов своих клиентов. Наиболее часто вносимыми типами изменений являются измененные списки закладок, нестандартные домашние страницы и нестандартные поисковые машины, но многие другие вещи также могут быть изменены. Эти измененные сборки генерируются для конечных релизов Firefox и не генерируются для бета-версий.

2.5. Добавление цифровых подписей

Для того, чтобы наши пользователи могли быть уверены в том, что они на самом деле скачали не модифицированную сборку от организации Mozilla, мы добавляем к сборкам несколько различных типов цифровых подписей.

Первый тип подписи используется для наших сборок, предназначенных для работы под управлением Windows. Мы используем ключ Microsoft Authenticode (технологии создания цифровой подписи на основе кода подписи) для добавления электронных подписей всем файлам с расширениями .exe и .dll. Windows может использовать эти подписи для проверки факта получения приложения из доверенного источника. Мы также подписываем исполняемый файл установщика Firefox с помощью ключа технологии Authenticode.

После этого мы используем GPG для генерации наборов контрольных сумм MD5 и SHA1 для всех сборок на всех платформах и генерируем отдельные подписи GPG для файлов контрольных сумм, а также для всех сборок и установщиков. Эти подписи используются зеркалами и участниками сообщества для проверки скачанных файлов.

В целях безопасности мы подписываем файлы на удаленной машине для генерации цифровых подписей, которая защищена от сторонних соединений с помощью межсетевого экрана и VPN. Наши электронные ключи, пароли и связки электронных ключей передаются между участвующими в подготовке релиза лицами исключительно посредством защищенных каналов, обычно персонально для возможной минимизации риска их раскрытия.

Добавление электронной подписи для установщиков Firefox
Рисунок 2.5: Добавление электронной подписи для установщиков Firefox

До недавнего времени процесс добавления электронных подписей требовал работы ответственного за выпуск релиза лица на удаленном сервере ("мастер-сервере электронных подписей"), которая занимала практически час и заключалась в ручной загрузке сборок, подписывании их и загрузке обратно на сервер ftp.mozilla.org перед тем, как автоматизированная система подготовки релиза сможет продолжить работу. После окончания процесса добавления электронных подписей на мастер-сервере и загрузки всех файлов, файл журнала осуществленных в ходе добавления подписей операций загружается в директорию для хранения релиз-кандидатов на сервере ftp.mozilla.org. Наличие этого файла журнала на сервере ftp.mozilla.org указывает на окончание ручной работы по добавлению подписей и с этого момента зависимые системы для сборки, которые следят за этим файлом, могут продолжить работу в автоматическом режиме. Не так давно мы добавили дополнительный уровень автоматизации для выполнения этапов добавления цифровых подписей. Теперь ответственное за выпуск релиза лицо может открыть командную оболочку Cygwin на мастер-сервере электронных подписей и установить несколько переменных окружения, имеющих отношение к релизу, таких, как VERSION, BUILD, TAG и RELEASE_CONFIG, которые позволят сценарию найти соответствующие директории на сервере ftp.mozilla.org и получить информацию о том, когда все сборки релиза будут загружены, после чего процесс добавления электронных подписей может начаться. После получения новейшей пригодной для эксплуатации версии наших инструментов для добавления электронных подписей подписывающий сборки человек может просто выполняет команду make autosign. После этого он вводит две ключевые фразы, одна из которых предназначается для gpg, а вторая - для кода подписи. Сразу после завершения автоматической проверки этих ключевых фраз средствами сценариев начинает выполняться автоматизированный цикл загрузки, в рамках которого производится наблюдение за загружаемыми сборками и повторно упакованными сборками, которые автоматически загружаются непосредственно после того, как становятся доступны. Сразу же после загрузки автоматизированная система начинает добавление цифровых подписей без необходимости человеческого вмешательства в процесс.

Отсутствие необходимости в человеческом вмешательстве при добавлении электронных подписей к сборкам важно по двум причинам. Во-первых, это обстоятельство позволяет сократить риск возникновения ошибки по вине человека. Во-вторых, это обстоятельство позволяет добавлять электронные подписи в нерабочее время, поэтому ответственному за выпуск релиза лицу не придется находиться за компьютером в неудобное для работы время.

Все сборки имеют соответствующие им файлы контрольных сумм MD5SUM и SHA1SUM, сгенерированные для них, причем значения контрольных сумм записываются в файлы с теми же именами, что и сборки. Эти файлы будут загружены назад в директорию для хранения релиз-кандидатов, а также будут скопированы в конечную директорию для хранения релизов на сервере ftp.mozilla.org, когда она будет создана, поэтому любой скачавший установщик Firefox с одного из наших зеркал человек может удостовериться в том, что он получил не модифицированный программный продукт. Когда все подписанные данные становятся доступны и проверяются, они загружаются назад на сервер ftp.mozilla.org вместе с файлом журнала операций добавления подписей, который ожидает система автоматизированной подготовки релиза.

Наш следующий запланированный этап усовершенствования процесса добавления электронных подписей заключается в создании инструмента, который позволит нам подписывать файлы одновременно с подготовкой сборки или повторной упаковкой сборки. Эта работа требует создания приложения для сервера электронных подписей, которое сможет принимать запросы на добавление подписей для файлов на машинах, осуществляющих сборку для выпуска релиза. Она также требует создания клиентского инструмента для работы с электронными подписями, который сможет соединяться с сервером электронных подписей, проходить аутентификацию, представляясь клиентом доверенной машины, которая в свою очередь может осуществлять запросы на добавление электронных подписей, ожидать добавления подписи, загружать подписанные данные и после этого включать их в состав сборки путем упаковки. Как только эти усовершенствования будут доступны для использования, мы сможем уйти от нашего последовательного процесса добавления всех электронных подписей, а также от нашего последовательного процесса генерации обновлений (о котором будет написано ниже). Мы ожидаем, что после выполнения этой работы общее время, затрачиваемое на подготовку релиза, сократится на несколько часов.

2.6. Обновления

Обновления создаются для того, чтобы пользователи с помощью встроенной системы обновления могли быстро и просто перейти к использованию новейшей версии Firefox без необходимости загрузки и запуска отдельного установщика. Загрузка пакета обновления происходит быстро и незаметно для пользователя в фоновом режиме. Только после того, как файлы обновления загружены и готовы для применения, Firefox предложит пользователю обновить установленную версию приложения и перезапустить его.

Сложность состоит в том, что мы генерируем очень много обновлений. Для серий релизов линии продуктов мы генерируем обновления, которые могут быть применены по отношению ко всем предыдущим поддерживаемым релизам серии для перехода к использованию новейшего релиза для данной линии продуктов. Для Firefox наличие новейшего релиза (LATEST) подразумевает необходимость генерации обновлений для каждой платформы, каждой локализации и каждого установщика, начиная с версий Firefox LATEST-1, LATEST-2, LATEST-3, ... в полной и частичной формах. В данный момент мы выпускаем обновления для нескольких различных линий продуктов.

Наша система автоматизации процесса генерации обновлений модифицирует конфигурационные файлы обновления каждой из сборок релиза ветви для поддержания в актуальном состоянии нашего простого списка соответствия между номерами версий, платформами и локализациями, для которых должны быть созданы обновления, позволяющие пользователям перейти на новый релиз. Мы предоставляем обновления в форме "фрагментов" информации. Как вы увидите в примере ниже, этот фрагмент информации является простым файлом в формате XML, расположенным на нашем сервере AUS (службы обновления приложения - Application Update Service), который информирует браузер Firefox на стороне пользователя о том, где расположены полные или частичные архивы обновлений в форме файлов с расширением .mar (архив Mozilla - Mozilla Archive).

Важные и незначительные обновления

<updates>
 <update type="minor" version="7.0.1" extensionVersion="7.0.1"
 buildID="20110928134238"
 detailsURL="https://www.mozilla.com/en-US/firefox/7.0.1/releasenotes/">
 <patch type="complete"
 URL="http://download.mozilla.org/?product=firefox-7.0.1-complete&os=osx&lang=en-US&force=1"
 hashFunction="SHA512"
 hashValue="7ecdbc110468b9b4627299794d793874436353dc36c80151550b08830f9d8c5afd7940c51df9270d54e11fd99806f41368c0f88721fa17e01ea959144f473f9d"
 size="28680122"/>
 <patch type="partial"
 URL="http://download.mozilla.org/?product=firefox-7.0.1-partial-6.0.2&os=osx&lang=en-US&force=1"
 hashFunction="SHA512"
 hashValue="e9bb49bee862c7a8000de6508d006edf29778b5dbede4deaf3cfa05c22521fc775da126f5057621960d327615b5186b27d75a378b00981394716e93fc5cca11a"
 size="10469801"/>
 </update>
</updates>

Рисунок 2.6: Пример фрагмента информации об обновлении

Как вы можете увидеть на Рисунке 2.6, фрагменты информации об обновлениях содержат атрибут type, который может иметь либо значение major (важное), либо значение minor (незначительное). Незначительные обновления позволяют пользователям перейти к использованию новейшей доступной версии установленного релиза; например, незначительные обновления позволят всем пользователям релиза 3.6.* перейти к использованию новейшей подверсии для релиза 3.6, всем пользователям бета-версии разрабатываемого релиза - перейти к использованию новейшей бета-версии, всем пользователям ночных сборок - перейти к использованию новейшей ночной сборки, и.т.д. Наиболее часто выпускаются именно незначительные обновления, которые не требуют от пользователя какого-либо вмешательства в процесс обновления помимо подтверждения намерения выполнения обновления и перезапуска браузера.

Важные обновления используются тогда, когда нам необходимо сообщить пользователям о том, что доступен новейший усовершенствованный релиз, причем в этом случае будет выведено сообщение "Доступна новая версия Firefox, желаете установить обновление?", а также рекламная страница, описывающая наиболее важные функции нового релиза. Внедрение нашей новой системы ускоренного выпуска релизов позволило прекратить выпуск большого количества важных обновлений; у нас появилась возможность прекращения генерации важных обновлений с момента окончания срока поддержки релиза 3.6.*.

Полные и частичные обновления

Во время сборки мы генерируем "полное обновление" в виде файлов с расширением .mar, которые содержат все файлы нового релиза, сжатые с помощью компрессора bz2, после чего добавленные в файл архива с расширением .mar. Как полные, так и частичные обновления автоматически загружаются по каналу доставки обновлений, в котором регистрируется установленная пользователем копия приложения Firefox. Мы используем различные каналы обновлений (то есть, пользователи релизов ожидают обновлений на канале для обновления релизов, пользователи бета-версий ожидают обновлений на канале для обновления бета-версий, и.т.д.), поэтому мы можем доставлять обновления, например, для пользователей релиза и пользователей бета-версии в различное время.

Файлы с расширением .mar частичных обновлений создаются путем сравнения файлов с расширением .mar для устаревшего релиза с файлами с расширением .mar для нового релиза и создания файла "частичного обновления" с расширением .mar, содержащего данные различий в бинарной форме для любых измененных файлов, а также файл манифеста. Как вы можете увидеть в примере фрагмента информации об обновлении на Рисунке 2.6 такой подход позволяет значительно сократить размер файлов частичных обновлений. Это очень важно для пользователей, работающих с медленными соединениями с Интернет или с соединениями с Интернет, реализованными по технологии dial-up.

В устаревших версиях нашей системы автоматизации процесс генерации частичных обновлений для всех локализаций и платформ мог длиться до семи часов для одного релиза, так как выполнялась этапы загрузки файлов полных обновлений с расширением .mar, поиска различий и упаковки данных частичных обновлений в файл с расширением .mar. В конечном счете было установлено, что даже для различных платформ многие изменения компонентов выполнялись идентично, следовательно многие данные различий могли быть повторно использованы. С помощью сценария, который кэшировал хэши для каждой части данных различий, время выполнения нашего процесса генерации обновлений сократилось примерно на 40 минут.

После того, как фрагменты информации об обновлениях загружаются и размещаются на сервере автоматической системы обновлений, шаг проверки наличия обновлений заключается в а) тестовой загрузке фрагментов информации об обновлениях и б) запуске системы обновления приложения для полученного файла с расширением .mar для подтверждения корректности применения обновлений.

Генерация файлов частичных обновлений с расширением .mar, как и фрагментов информации об обновлениях на данный момент начинается после завершения процесса добавления электронных подписей. Мы выполняем действия в такой последовательности из-за того, что генерация частичных обновлений должна выполняться для файлов двух подписанных релизов и, следовательно, генерация фрагментов информации об обновлениях должна быть задержана до момента доступности подписанных сборок. Как только у нас появится возможность интеграции процесса добавления электронных подписей в процесс генерации сборок, мы сможем генерировать частичные обновления непосредственно после завершения процесса генерации сборки или повторной упаковки. Вместе с усовершенствованиями программного обеспечения нашей системы автоматического обновления, мы получим возможность размещать на зеркалах законченные сборки и повторно упакованные сборки непосредственно после завершения процесса генерации сборок. Это обстоятельство позволяет эффективно осуществлять создание обновлений в параллельном режиме, сокращая общее время их создания на несколько часов.

2.7. Размещение сборок на внутренних зеркалах и контроль качества

Проверка того, что в результате выполнения процесса подготовки релиза был получен ожидаемый результат, является ключевым шагом. Этот шаг осуществляется группой контроля качества в ходе процесса проверки и подтверждения работоспособности сборки.

Как только подписанные сборки становятся доступны, группа контроля качества начинает ручное и автоматизированное тестирование. Контроль качества осуществляется участниками сообщества, нанятыми сторонними специалистами, а также работниками организации, находящимися в различных часовых поясах с целью возможного ускорения этого процесса проверки работоспособности. В то же время наша автоматизированная система подготовки релизов генерирует обновления для всех языков и всех платформ, которые смогут быть применены ко всем поддерживаемым релизам. Фрагменты информации об обновлениях обычно становятся доступны до того момента, как группа контроля качества завершает проверку подписанных сборок. После этого группа контроля качества проверяет возможность осуществления пользователями безопасного обновления при использовании предыдущих релизов для перехода к использованию новейшего релиза с помощью представленных обновлений.

Технически наша система автоматизации перемещает бинарные файлы на наши "внутренние зеркала" (серверы, обслуживаемые организацией Mozilla) с целью проверки обновлений группой контроля качества. Только после того, как группа контроля качества закончит проверку сборок и обновлений, мы перемещаем их на наши зеркала сообщества. Эти зеркала сообщества важны для обработки нагрузки, создаваемой пользователями со всего мира, так как они позволяют пользователям осуществлять запросы обновлений с локальных узлов, на которых размещаются зеркала, вместо отправки запроса напрямую серверу ftp.mozilla.org. В том, что мы не делаем сборки и обновления доступными на серверах сообщества до подтверждения их работоспособности группой контроля качества нет ничего плохого, так как в последний момент вполне могут возникнуть сложности в случае обнаружения группой контроля качества серьезной ошибки, в результате чего релиз-кандидат будет отозван.

Процесс проверки работоспособности после завершения генерации сборок и обновлений состоит из следующих этапов:

Следует отметить, что пользователи не получат обновления до тех пор, пока группа контроля качества не подтвердит их работоспособность и координатор релиза не отправит электронное сообщение с командой для перемещения сборок и обновлений в публичный репозиторий.

2.8. Перемещение файлов на публичные зеркала и в систему автоматического обновления

Как только координатор релиза получает подтверждение работоспособности программных компонентов от группы контроля качества и других различных групп организации Mozilla, группа подготовки релизов может продолжить работу, перемещая файлы на серверы из сети зеркал сообщества. Мы используем серверы сообщества для того, чтобы иметь возможность обрабатывать запросы от нескольких сотен миллионов пользователей, загружающих обновления в течение нескольких следующих дней. Все установщики наряду с полными и частичными обновлениями для всех платформ и локализаций в этот момент уже находятся на серверах нашей внутренней сети зеркал. Процесс публикации файлов на внешних зеркалах предполагает внесение изменений в файл исключений приложения rsync для задействования модуля публичных зеркал. После осуществления этих изменений файла начнется синхронизация зеркал, в ходе которой будет осуществлено копирование файлов новых релизов. Каждое зеркало имеет ассоциированный параметр рейтинга или веса; мы отслеживаем то, какие зеркала содержат синхронизированные файлы и суммируем их индивидуальные рейтинги для вычисления общего показателя "распространения" данных. После того, как достигается определенное пороговое значение распространения данных, мы информируем координатора релиза о том, что на зеркалах содержится достаточное количество копий данных для распространения релиза.

Это тот момент, когда релиз становится "официальным". После того, как координатор релиза отправляет финальное сообщение с командой "отправка на публикацию", группа подготовки релизов обновит символьные ссылки на веб-сервере, таким образом посетители наших веб- и ftp-ресурсов смогут найти новейшую версию Firefox. Мы также публикуем все фрагменты информации об обновлениях для пользователей прошлых версий Firefox на ресурсах системы автоматического обновления.

Установленные на пользовательских машинах копии приложения Firefox регулярно проверяют доступность обновленной версии Firefox на серверах системы автоматического обновления. После того, как мы публикуем эти фрагменты информации об обновлениях, у пользователей появляется возможность автоматически обновить Firefox до новейшей версии.

2.9. Выученные уроки

Как у инженеров, у нас возникает желание немедленно начать работу над исправлением очевидных технических проблем при их обнаружении. Однако, процесс подготовки релиза затрагивает несколько областей, как технических, так и не технических, поэтому очень важно быть готовым к решению и технических, и не относящихся к техническим вопросов.

Важность взаимодействия с другими заинтересованными сторонами

Важно быть уверенным, что все заинтересованные стороны понимают то, что наш хрупкий процесс подготовки релизов подвергает организацию, а также наших пользователей различным рискам. Эти риски охватывают все уровни организации, допуская потери бизнес-возможностей и изменение позиций на рынке программных продуктов, причиной которых может оказаться медленная и неустойчивая автоматизированная система подготовки релизов. Более того, возможность организации Mozilla защищать своих пользователей путем сверхбыстрого выпуска релизов приобрела большую важность по мере роста пользовательской базы, что в свою очередь сделало наш продукт более привлекательной целью для злоумышленников.

Интересным фактом можно назвать то, что некоторые люди, однажды столкнувшиеся с неустойчивой системой подготовки релизов во время своей профессиональной деятельности, приходят в организацию Mozilla с негативным отношением к подобным системам, выражающимся утверждением: "да уж, эти системы всегда работают плохо". Объяснение преимуществ, ожидаемых занимающейся бизнесом организацией при внедрении надежной масштабируемой системы автоматизированной подготовки релизов помогает любому человеку понять важность "незаметной" работы группы подготовки релизов, заключающейся в улучшении этой системы, которую мы и пытаемся выполнять.

Привлечение других групп

Для того, чтобы сделать процесс подготовки релизов более эффективным и надежным, группа подготовки релизов, а также другие группы организации Mozilla должны выполнять соответствующую работу. Однако, интересно посмотреть на то, как часто выражение "требуется много времени для выпуска релиза" неверно трактуется, как "группе подготовки релизов требуется много времени для подготовки релиза". Эта неверная трактовка игнорировала работу по подготовке релиза, выполняемую всеми группами за исключением группы подготовки релизов, и снижала мотивацию участников группы подготовки релизов. Исправление этой неверной трактовки требовало дополнительного обучения персонала организации Mozilla, заключающегося в объяснении того, на что на самом деле тратится большая часть времени различными группами в процессе подготовки релиза. Мы проводили это обучение, пользуясь не технологичными "обычными" метками времени в сообщениях электронной почты с четко описанными командами взаимодействия между группами, а также сериями "периодических" записей в блогах с детальным описанием того, на что было потрачено время.

Установление четких взаимосвязей

Многие из наших "проблем при подготовке релиза" на самом деле являются проблемами людей: нарушение взаимодействия между группами; недостаточно развитые лидерские качества; а также преобладающее стрессовое состояние, утомление и тревога в в процессе подготовки chemspill-релизов. После установления четких взаимосвязей между группами для устранения этих нарушений взаимодействий между людьми, процесс подготовки наших релизов сразу же стал более плавным и взаимодействия людей из разных групп очень быстро улучшились.

Управление процессом работы

На начальных этапах реализации данного проекта мы очень часто теряли участников групп. Это само по себе очень плохо. В этой ситуации следует учитывать факт, заключающийся в отсутствии точной актуальной документации и подразумевающий то, что большая часть технических особенностей процесса подготовки релиза была документирована исключительно в устной форме, при этом мы теряли часть данной информации каждый раз, когда участник покидал группу. Нам нужно было срочно изменить сложившееся положение вещей.

Мы пришли к выводу о том, что лучшим способом повышения самосознания и демонстрации улучшения ситуации является предоставление возможности людям убедиться в том, что у нас есть план улучшения ситуации, причем эти люди в некоторой степени смогут изменять его по своему усмотрению. Мы добились этого, гарантируя резервирование времени для исправления по крайней мере одной любой! недоработки после выпуска каждого релиза. Нам удалось реализовать эту возможность путем введения непосредственно после выпуска релиза периода, длящегося в течение одного или двух дней, когда участников групп "не должны беспокоить". Незамедлительное решение мелких проблем в момент, пока о них еще помнят люди, помогло кратковременно отвлечь внимание людей для того, чтобы они впоследствии сфокусировали его на более масштабных проблемах последующих релизов. Более важным является тот факт, что данный подход дал людям ощущение контроля над своим будущим, и процесс подготовки релизов на самом деле начал выполняться более успешно.

Управление изменениями

Из-за давления рынка браузеров бизнес-модель и особенности продуктов организации Mozilla потребовали изменения процесса выпуска релизов в момент нашей работы над его улучшением. Это нестандартная ситуация, которую нужно ожидать.

Мы знали о том, что нам придется выпускать релизы с использованием действующего процесса их подготовки в то время, как мы будем внедрять новый процесс. В связи с этим нами было принято решение об отказе от создания отдельного проекта "greenfield" в процессе работы над существующими системами; мы чувствовали, что действующие системы были настолько хрупкими, что у нас буквально не останется времени на создание чего-либо другого.

Также мы с самого начала предполагали, что нам не будут полностью понятны причины некорректной работы систем. Каждое последовательное улучшение позволяло нам сделать шаг назад и проверить, не таит ли оно в себе новых сюрпризов, перед тем, как начинать работу над новым улучшением. Такие фразы, как "осушение болот", "очистка лука" и "как это вообще работало?" звучали постоянно при обнаружении сюрпризов в ходе работы над данным проектом.

Принимая все это во внимание, мы решили постоянно вносить большое количество незначительных улучшений в существующий процесс. Каждое последовательное улучшение делало следующий релиз немного качественнее. И, что более важно, каждое улучшение высвобождало немного больше времени при выпуске следующего релиза, что позволяло инженеру использовать немного больше времени для внесения следующего улучшения. Эти улучшения накапливались до достижения нами переломного момента, после чего мы начали выделять время для работы над значительными важными улучшениями. В этот момент преимущества оптимизаций процесса подготовки релизов стали действительно заметны.

2.10. Дополнительная информация

Мы действительно гордимся выполненной работой и теми возможностями, которые стали доступны организации Mozilla на новом развивающимся глобальном рынке веб-браузеров.

Четыре года назад выпуск двух chemspill-релизов в месяц был бы темой оживленного обсуждения в Mozilla. В отличие от этого, на прошлой неделе был опубликован эксплоит для сторонней библиотеки, из-за чего организации Mozilla пришлось выпустить восемь chemspill-релизов в течение двух неполных рабочих дней.

Принимая во внимание описанные выше возможности, наша система автоматизированной подготовки релизов все еще может быть усовершенствована, при этом наши требования и запросы продолжают меняться. Для того, чтобы узнать о нашей предстоящей работе, обратитесь к следующим источникам информации:

3.1. Что такое «встроенная» и «реального времени»?

«Встроенная» и «реального времени» может означать разное для разных людей, поэтому давайте определим, как эти понятия используются в системе FreeRTOS.

Встроенная система представляет собой компьютерную систему, которая разработана для выполнения всего лишь нескольких задач, например, система в пульте дистанционного управления телевизором, автомобильная система GPS, цифровые часы или кардиостимулятор. Встроенные системы, как правило, меньше и медленнее, чем компьютерные системы общего назначения, и, как правило, также дешевле. Типичная недорогая система может иметь 8-разрядный процессор с тактовой частотой 25 МГц, несколькими килобайтами оперативной памяти, и, возможно, 32 Кб флэш-памяти. В более дорогих встроенных системах может быть 32-разрядный процессор с тактовой частотой 750 МГц, 1 ГБ оперативной памяти, а также несколько гигабайтов флеш-памяти.

Системы реального времени конструируются таким образом, чтобы они успевали что-то выполнить в течение определенного промежутка времени; они гарантируют, что что-то будет выполнено так, как предполагается.

Отличным примером встроенной системы реального времени является кардиостимулятор. Кардиостимулятор должен сокращать сердечную мышцу в нужное время с тем, чтобы мы оставались в живых; он не должен быть слишком занят, чтобы не ответить вовремя. Кардиостимуляторы и другие встроенные системы реального времени разрабатываются очень тщательно с том, чтобы каждый раз вовремя выполнять свои задачи.

3.2. Обзор архитектуры

Система FreeRTOS является относительно небольшим приложением. Минимальный вариант ядра системы FreeRTOS состоит всего лишь из трех файлов исходного кода (.c) и горстка файлов заголовков, общий размер которых составляет чуть менее 9000 строк кода, включая комментарии и пустые строки. Размер типичного образа системы в исполняемом коде меньше 10 КБ.

Код FreeRTOS подразделяется на три основные части: выполнение задач, коммуникация и интерфейс с аппаратным обеспечением.

Об аппаратном обеспечении

Аппаратно-независимый слой системы FreeRTOS находится поверх аппаратно-зависимого слоя. В этом аппаратно-зависимом слое известно, как взаимодействовать с архитектурой, построенной на любом чипе, который вы выберете. На рис.3.1 показаны слои системы FreeRTOS.

Рис.3.1: Слои программного обеспечения системы FreeRTOS

Система FreeRTOS поставляемый со всем аппаратно-независимым, а также с аппаратно-зависимым кодом, который вы должны получить для того, чтобы настроить и запустить систему. Поддерживается большое количество компиляторов (CodeWarrior, GCC, IAR и т.д.), а также большое количество архитектур процессоров (ARM7, ARM Cortex-M3, различные PIC, Silicon Labs 8051, x86 и т.д.). Список поддерживаемых архитектур и компиляторов смотрите на сайте системы FreeRTOS.

Дизайн системы FreeRTOS позволяет ее легко настраивать в широких пределах. Система FreeRTOS может собрана как для одного процессора, для выполнения только основных функций и поддержки только нескольких задач, либо она может быть собрана для работы на многоядерном железе с TCP/IP, файловой системой и USB.

Параметры конфигурации выбираются в файле с помощью задания различных значений #defines. В этом файле можно выбрать тактовую частоту, размер кучи, мютексы и подмножество API, а также многие другие параметры. Ниже приведено несколько примеров, в которых устанавливается максимальное количество уровней приоритетов задач, частота процессора, тактовая частота системы, минимальный размер стека и общий размер кучи:

#define configMAX_PRIORITIES      ( ( unsigned portBASE_TYPE ) 5 )
#define configCPU_CLOCK_HZ        ( 12000000UL )
#define configTICK_RATE_HZ        ( ( portTickType ) 1000 )
#define configMINIMAL_STACK_SIZE  ( ( unsigned short ) 100 )
#define configTOTAL_HEAP_SIZE     ( ( size_t ) ( 4 * 1024 ) )

Аппаратно-зависимую код для каждого инструментального набора компиляции и для каждой архитектуры процессора располагается в отдельных файлах. Например, если вы работаете с компилятором IAR на чипе ARM Cortex-M3, то аппаратно-зависимый код будет находиться в каталоге FreeRTOS/Source/portable/IAR/ARM_CM3/. В файле portmacro.h объявляются все аппаратно-зависимые функции, а в файлах port.c и portasm.s находится весь фактически используемый аппаратно-зависимый код. Во время компиляции аппаратно-независимые заголовки #include файла portable.h заменяются файлом portmacro.h. Система FreeRTOS вызывает аппаратно-зависимые функции с помощью функций #define, объявленных в файле portmacro.h.

Давайте посмотрим на пример того, как система FreeRTOS вызывает аппаратно-зависимую функцию. Для того, чтобы войти в критическую секцию кода для предотвращения вытеснения, часто требуется аппаратно-независимый файл tasks.c. В различных архитектурах вход в критическую секцию происходит по-разному, и, желательно, чтобы аппаратно-независимый файл tasks.c не касался деталей, связанных с аппаратной зависимостью. Поэтому tasks.c вызывает глобальный макрос portENTER_CRITICAL(), радуясь тому, что не требуется знать как и что на самом деле работает. Предположим, что мы используем компилятор IAR на чипе ARM Cortex-M3, система FreeRTOS будет собираться с заголовками FreeRTOS/Source/portable/IAR/ARM_CM3/portmacro.h, в который макрос portENTER_CRITICAL() определяется следующим образом:

#define portENTER_CRITICAL()   vPortEnterCritical()

На самом деле vPortEnterCritical() определяется в файле FreeRTOS/Source/portable/IAR/ARM_CM3/port.c. Файл port.c является аппаратно-зависимым и содержит код, который понятен компилятору IAR и чипу Cortex-M3. vPortEnterCritical() выполняет переход в критическую секцию, используя знания, касающиеся конкретного оборудования, и возвращает управление аппаратно-независимому файлу tasks.c.

В файле portmacro.h также определяются базовые типы данных для используемой архитектуры. Ниже приведен пример типов данных для базовых целочисленных переменных, указателей и данные тактовой частоты системы для компилятора IAR на чипе ARM Cortex-M3:

#define portBASE_TYPE  long              // Тип базовой целочисленной переменной
#define portSTACK_TYPE unsigned long     // Указатели на позиции в памяти
typedef unsigned portLONG portTickType;  // Тип тактовой частоты системы

Такой метод использования типов данных и функций через тонкие слои определений #defines может показаться несколько сложным, но он позволяет перекомпилировать систему FreeRTOS для архитектуры с совершенно другой системой, изменив только аппаратно-зависимые файлы. И если вы хотите запустить систему FreeRTOS на архитектуре, которая в настоящее время не поддерживается, вам потребуется реализовать только аппаратно-зависимую функциональность, которая значительно меньше, чем аппаратно-независимая часть системы FreeRTOS.

Как мы уже видели, система FreeRTOS реализует аппаратно-зависимую функциональность с помощью макросов #define препроцессора языка C. В системе FreeRTOS определения #define также используются большей части аппаратно-независимого кода. Для невстраиваемых приложений такое частое использование определений #define является кардинальным недостатком, но во многих небольших встроенных системах накладные расходы на вызов функции не дают преимуществ в сравнении с использованием «реальных» функций.

3.3. Планирование задач: Краткий обзор

Приоритеты задач и список готовности

Каждая задача имеет приоритет, назначенный пользователем, который равен от 0 (самый низкий приоритет) и до значения времени компиляции configMAX_PRIORITIES-1 (самый высокий приоритет). Например, если configMAX_PRIORITIES установлен равным 5, то система FreeRTOS будет использовать 5 уровней приоритета: 0 (самый низкий приоритет), 1, 2, 3, и 4 (наивысший приоритет).

В системе FreeRTOS используется «список готовности» («ready list») для отслеживания всех задач, которые в настоящее время готовы к запуску. В ней список готовности реализуется как массив списков задач наподобие следующего:

static xList pxReadyTasksLists[ configMAX_PRIORITIES ];  /* Задачи, готовые согласно приоритетам.  */

Список pxReadyTasksLists[0] является списком всех готовых задач с приоритетом 0, список pxReadyTasksLists[1] является списком всех готовых задач с приоритетом 1 и так далее до списка pxReadyTasksLists[configMAX_PRIORITIES-1].

Тактовая частота системы

Пульсом системы FreeRTOS является ее тактовая частота. Система FreeRTOS настраивается таким образом, чтобы периодически выдавать прерывания. Пользователь может регулировать частоту рерываний, которая обычно находится в диапазоне миллисекунд. Каждый раз, когда возникает прерывание, вызывается функция vTaskSwitchContext(). Функция vTaskSwitchContext() выбирает задачу с наибольшим приоритетом готовности и помещает ее в переменную pxCurrentTCB, например, следующим образом:

/* Поиск очереди с наивысшим приоритетом, в которой есть готовые к запуску задачи. */
while( listLIST_IS_EMPTY( &( pxReadyTasksLists[ uxTopReadyPriority ] ) ) )
{
    configASSERT( uxTopReadyPriority );
    --uxTopReadyPriority;
}

/* listGET_OWNER_OF_NEXT_ENTRY проходит по списку, поскольку задачи с одинаковым приоритетом 
получают одинаковое право пользоваться процессорным временем. */
listGET_OWNER_OF_NEXT_ENTRY( pxCurrentTCB, &( pxReadyTasksLists[ uxTopReadyPriority ] ) );

Перед тем как будет запущен цикл, гарантируется, что значение uxTopReadyPriority будет больше или равно приоритету задаче, готовой к запуску и имеющей наивысший приоритет. Цикл while() начинается с уровня приоритета uxTopReadyPriority и двигается вниз по массиву pxReadyTasksLists[] с тем, чтобы самый высокий уровень приоритета с задачами, готовыми к запуску. Затем функция listGET_OWNER_OF_NEXT_ENTRY() забирает следующую готовую к запуску задачу из списка готовых задач с этим уровнем приоритета.

Теперь pxCurrentTCB указывает на задачу с наивысшим приоритетом, а наиболее приоритетных задач, а когда функция vTaskSwitchContext() вернет управление, аппаратно-зависимый кон начнет выполнение этой задачи.

Эти девять строк кода являются, по настоящему, сердцем системы FreeRTOS. Остальные более 8900 строк системы FreeRTOS существуют лишь для того, чтобы удостовериться, что эти девять строк делают все необходимое, чтобы поддерживать выполнение задачи с наибольшим приоритетом.

На рис.3.2 приведена общая схема того, как выглядит список задач, готовых к выполнению. В этом примере есть три уровня приоритета, с одной задачей на уровне приоритета 0, без задач на уровне приоритета 1 и с тремя задачами на уровне приоритета 2. Эта картина абсолютно точная, но не полная, здесь не хватает нескольких деталей, которые мы добавим позже.

Рис.3.2: Общая схема списка готовности задач Ready List в системе FreeRTOS

Теперь, когда у нас есть общая схема пути, давайте погрузимся в детали. Мы рассмотрим три основные структуры данных системы FreeRTOS: задачи, списки и очереди.

3.4. Задачи

Основная работа всех операционных систем состоит в запуске и координации работы пользовательских задач. Подобно многим операционным системам, основной единицей работы в системе FreeRTOS является задача. В системе FreeRTOS для представления каждой задачи используется блок управления задачей (Task Control Block - ТСВ).

Блок управления задачей TCB

Блок TCB определяется в tasks.c следующим образом:

typedef struct tskTaskControlBlock
{
  volatile portSTACK_TYPE *pxTopOfStack;                  /* Указывает на месторасположение
                                                             последнего элемента, размещенного
                                                             в стеке задач. ЭТО 
                                                             ДОЛЖЕН БЫТЬ ПЕРВЫЙ ЭЛЕМЕНТ
                                                             СТРУКТУРЫ STRUCT. */
                                                         
  xListItem    xGenericListItem;                          /* Элемент списка, используемый для 
                                                             помещения блока TCB в очереди 
                                                             готовых и заблокированных задач. */
  xListItem    xEventListItem;                            /* Элемент списка, используемый для 
                                                             помещения блока TCB в списки событий.*/
  unsigned portBASE_TYPE uxPriority;                      /* Приоритет задачи;
                                                             0 является низшим
                                                             приоритетом. */
  portSTACK_TYPE *pxStack;                                /* Указывает на начало
                                                             стека. */
  signed char    pcTaskName[ configMAX_TASK_NAME_LEN ];   /* Описательное имя, которое 
                                                             присваивается стеку, когда он 
                                                             создается. Используется только
                                                             для отладки. */

  #if ( portSTACK_GROWTH > 0 )
    portSTACK_TYPE *pxEndOfStack;                         /* Используется для проверки стека 
                                                             на переполнение в тех архитектурах,
                                                             где стек растет с младших
                                                             адресов памяти. */
  #endif

  #if ( configUSE_MUTEXES == 1 )
    unsigned portBASE_TYPE uxBasePriority;                /* Приоритет, назначенный задаче
                                                             последним - 
                                                             используется механизмом
                                                             наследования приоритетов. */
  #endif

} tskTCB;

В блоке TCB в переменной pxStack хранится адрес начала стека, а в переменной pxTopOfStack - текущая вершина стека. В нем также в переменной pxEndOfStack хранится указатель на конец стека для проверки стека на переполнение в случае, если стек растет «вверх» в сторону старших адресов. Если стек растет «вниз» к младшим адреса, то переполнение стека проверяется путем сравнения текущей вершины стека с началом стека, которое хранится в переменной pxStack.

В блоке TCB в переменных uxPriority и uxBasePriority хранится начальный приоритет задачи. Задача дается приоритет, когда она создается, и приоритет задачи может быть изменен. Если в системе FreeRTOS реализовано наследование приоритетов, то переменная uxBasePriority используется для вспоминания первоначального приоритета, когда временно возводится до «наследуемого» приоритета. Подробности, касающиеся наследования приоритетов, приведены ниже в обсуждении мютексов.

В каждой задаче есть два элемента списка для использования в различных списках планирования в системе FreeRTOS. Когда задача добавляется в список, система FreeRTOS не вставляет указатель непосредственно в блок TCB. Вместо этого, он вставляет указатель либо в переменную xGenericListItem, либо в переменную xEventListItem блока TCB. Эти переменные xListItem позволяют системе FreeRTOS организовывать списки более хитро, чем если бы в них был указатель на блок TCB. Мы увидим это на примере позже, когда будем обсуждать списки.

Задача может находиться в одном из четырех состояний: выполняться, готова к выполнению, приостановлена или блокирована. Можно было бы ожидать, что в каждой задаче есть переменная, которая сообщает системе FreeRTOS о том, в каком состоянии задача находится, но это не так. Вместо этого, система FreeRTOS отслеживает состояние задачи неявно, помещая задачи в соответствующий список: список задач, готовых для выполнения, список приостановленных задач и т.д. Присутствие задачи в конкретном списке указывает состояние задачи. Когда задача переходит из одного состояния в другое, система FreeRTOS просто перемещает ее из одного списка в другой.

Настройка задачи

Мы уже затронули вопрос о том как задача выбирается и как планируется на исполнение с массивом pxReadyTasksLists; теперь давайте посмотрим на то, как первоначально создается задача. Задача создается, когда вызывается функция xTaskCreate(). FreeRTOS использует только что выделенный блок TCB объект для хранения имени, приоритета и другие деталей, касающиеся задачи, а затем выделяет некоторое количество памяти из стека по запросам пользователя (если в наличии есть достаточно памяти) и запоминает начало стека в элементе pxStack блока пользователя TCB.

Стек инициализируется таким образом, как будто новая задача уже запущена и была прервана переключением контекста. Таким образом, планировщик может рассматривать новые только что созданные задачи точно так же, как те, что уже работали некоторое время; планировщику не требуется какой-либо специальный код для обработки новых задач.

Способ, с помощью которого стек задачи создается таким образом, чтобы он выглядел, как если бы задача была прервана переключением контекста, зависит от архитектуры, на которой работает система FreeRTOS; хорошим примером является следующая реализация для процессора ARM Cortex-M3:

unsigned int *pxPortInitialiseStack( unsigned int *pxTopOfStack, 
                                     pdTASK_CODE pxCode,
                                     void *pvParameters )
{
  /* Эмулирует фрейм стека как если бы он был создан прерыванием переключателя контекста. */
  pxTopOfStack--; /* Смещение добавляется к значению счетчика — так MCU использует стек при 
                     переходе на прерывание/выходе из прерывания. */
  *pxTopOfStack = portINITIAL_XPSR;  /* xPSR */
  pxTopOfStack--;
  *pxTopOfStack = ( portSTACK_TYPE ) pxCode;  /* PC */
  pxTopOfStack--;
  *pxTopOfStack = 0;  /* LR */
  pxTopOfStack -= 5;  /* R12, R3, R2 and R1. */
  *pxTopOfStack = ( portSTACK_TYPE ) pvParameters;  /* R0 */
  pxTopOfStack -= 8;  /* R11, R10, R9, R8, R7, R6, R5 and R4. */
  
  return pxTopOfStack;
}

Когда происходит прерывание задачи процессор ARM Cortex-M3 помещает регистры в стек, когда задача прерывается. Функция pxPortInitialiseStack() изменяет стек так, чтобы он выглядел как будто в него были помещены регистры, хотя в действительности задача даже не начала выполняться. Для регистров xPSR, PC, LR и R0 процессора ARM в стеке хранятся известные значения. Остальные регистры R1 - R12 получить в стеке память, выделенное для них путем уменьшения верхней части указателя стека, но для этих регистров в стеке не хранятся какие-либо конкретные данных. В архитектуре ARM определяется, что при перезагрузке значения этих регистров не определены, поэтому в программе (не имеющей ошибок) не следует считать, что там хранятся известные значения.

После того, как стек подготовлен, задача почти готова к запуску. Однако, во-первых, система FreeRTOS отключает прерывания: Мы собираемся начать с передачей готовых списков и других структур планировщика, и мы не хотим, чтобы кто-нибудь, кроме нас, их менял.

Если это первая задача, которую когда-либо была создана создан, то система FreeRTOS инициализация списки задач планировщика. В планировщике системы FreeRTOS есть массив списков готовых задач pxReadyTasksLists[], в котором для каждого возможного уровня приоритета есть один список готовых задач. В системе FreeRTOS также есть несколько других списков, используемых для отслеживания задач, которые были приостановлены, уничтожены или задержаны. Сейчас они все инициализируются.

После того, как будет сделана любая первая инициализация, новая задача добавляется в список готовых задач, соответствующий указанному в ней уровню приоритета. Будут активированы прерывания и создание новой задачи завершится.

3.5. Списки

После задач, следующими наиболее часто используемыми в системе FreeRTOS являются списки. Система FreeRTOS использует структуру списков для отслеживания состояния задач при планирования, а также для реализации очередей.

Рис.3.3: Полная схема списка готовности задач Ready List в системе FreeRTOS

Список в системе FreeRTOS является стандартным закольцованным двусвязным списком с парой интересных дополнений. Элементы списка следующие:

struct xLIST_ITEM
{
  portTickType xItemValue;                   /* Значение, помещаемое в список. В большинстве
                                                случае используется для сортировки 
                                                списка в порядке уменьшения значений */
  volatile struct xLIST_ITEM * pxNext;       /* Указатель на следующий элемент xListItem 
                                                в списке.  */
  volatile struct xLIST_ITEM * pxPrevious;   /* Указатель на предыдущий элемент xListItem 
                                                в списке. */
  void * pvOwner;                            /* Указатель на объект (обычно блок TCB),
                                                в котором находится элемент списка. Таким 
                                                образом, организуется двусвязный список 
                                                между объектами, хранящимися в списке, и  
                                                элементами самого списка. */
  void * pvContainer;                        /* Указатель на список (если таковой имеется),
                                                в который этот элемент списка помещается. */
};

В каждом элементе хранится номер, xItemValue, которое обычно является приоритетом задачи, который отслеживается, или значением таймера для планирования событий. Списки хранятся в порядке убывания приоритета, а это означает, что наивысший приоритет xItemValue (наибольшее число) находится в начале списка и самый низкий приоритет xItemValue (наименьшее число) находится в конце списка.

Указатели pxNext и pxPrevious являются стандартными указателями, связывающие элементы списке. Указатель pvOwner является указателем на владельца элемента списка. Обычно это указатель на блок TCB задачи. Указатель pvOwner используется для быстрого переключения задач в vTaskSwitchContext(): как только в pxReadyTasksLists[] будет найден элемент списка с наивысшим приоритетом, указатель pvOwner элемента списка позволит нам непосредственно перейти к блоку TCB, который нужен при планировании запусков задачи.

Указатель pvContainer указывает на список, в котором находится этот элемент. Он используется для быстрого определения, принадлежит ли элемент некоторому списку. Каждый элемент списка может быть помещен в список, что осуществляется следующим образом:

typedef struct xLIST
{
  volatile unsigned portBASE_TYPE uxNumberOfItems;
  volatile xListItem * pxIndex;           /* Используется для прохода по списку. Указывает
                                             на последний элемент, возвращенный 
                                             функцией pvListGetOwnerOfNextEntry (). */
  volatile xMiniListItem xListEnd;        /* Элемент списка, в котором находится максимально 
                                             возможное значение, означающее, что он всегда 
                                             находится в конце списка и, поэтому,  
                                             используется как маркер. */
} xList;

Размер списка в любое время хранится в переменной uxNumberOfItems, предназначенной для быстрого выполнения операций с размерами списков. Все новые списки инициализируются таким образом, что в них есть один элемент: элемент xListEnd. Значение xListEnd.xItemValue является контрольным значением, равным наибольшему значению для переменной xItemValue: 0xffff в случае, если portTickType представляет собой 16-битное значение, и 0xffffffff в случае, если portTickType представляет собой 32-битное значение. Другие элементы списка также могут иметь такое же значение; алгоритм вставки элементов списка гарантирует, что xListEnd всегда будет последним элементом в списке.

Поскольку списки сортируются в порядке убывания, элемент xListEnd используется как маркер начала списка. А поскольку список кольцевой, этот элемент xListEnd также является маркером конца списка.

В большинстве «традиционных» операций доступа к списку, которыми вы пользуетесь, вся работа выполняется в одном цикле for() или в функции, вызываемой следующим образом:

for (listPtr = listStart; listPtr != NULL; listPtr = listPtr->next) {
  // Что-то здесь делается с указателями listPtr ...
}

В системе FreeRTOS часто требуется получить доступ к спискам сразу с помощью нескольких циклов for() и while(), а также с помощью нескольких вызовов функций, и поэтому происходит обращение к функциям, в которых при обходе списка используется указатель pxIndex. Функция listGET_OWNER_OF_NEXT_ENTRY() выполняет операцию pxIndex = pxIndex->pxNext; и возвращает значение pxIndex. (Конечно, также осуществляется соответствующее определение конца списка). Таким образом, во время перемещения по списку с использованием pxIndex сам список отвечает за отслеживание «текущего положения», позволяя остальной части системы FreeRTOS не беспокоиться об этом.

Рис.3.4: Полная схема списка готовности задач Ready List в системе FreeRTOS после возникновения прерывания

То, что все действия со списком pxReadyTasksLists[] выполняются в функции vTaskSwitchContext(), является хорошим примером того, как используется указатель pxIndex. Давайте предположим, что у нас есть только один уровень приоритета, приоритет 0, и есть три задачи на этом уровне приоритета. Это похоже на общую схему списка готовности задач, которую мы рассмотрели ранее, но на этот раз мы будем рассматривать все структуры и поля данных.

Как видно на рис.3.3, pxCurrentTCB указывает, что в настоящее время выполняется Задача B. В следующий момент времени выполняется функция vTaskSwitchContext(), она вызывает функцию listGET_OWNER_OF_NEXT_ENTRY() с тем, чтобы перейти к запуску следующей задачи. Эта функция использует pxIndex->pxNext для того, чтобы выяснить, что следующей задачей является Задача С, и теперь pxIndex указывает на элемент списка Задачи C, а pxCurrentTCB указывает на блок TCB Задачи С так, как показано на рис.3.4.

Обратите внимание, что каждый объект struct xListItem является, на самом деле, объектом xGenericListItem из соответствующего блока TCB.

3.6. Очереди

Система FreeRTOS позволяет задачам с помощью очередей общаться и синхронизироваться друг с другом. Процедуры сервиса прерываний (ISR) также используют очереди для взаимодействий и синхронизации.

Базовая структура данных очереди выглядит следующим образом:

typedef struct QueueDefinition
{
  signed char *pcHead;                      /* Указывает на начало области хранения 
                                               очереди. */
  signed char *pcTail;                      /* Указывает на байт в конце области хранения 
                                               очереди. Еще один байт требуется поскольку 
                                               хранятся отдельные элементы очереди;
                                               он используется как маркер. */
  signed char *pcWriteTo;                   /* Указывает на следующее свободное место в
                                               в области хранения очереди. */
  signed char *pcReadFrom;                  /* Указывает на последнюю позицию, откуда 
                                               происходило чтение очереди. */
                                           
  xList xTasksWaitingToSend;                /* Список задач, которые блокированы, ожидая 
                                               пока не произойдет обращение к этой очереди; 
                                               Запомнены в порядке приоритета. */
  xList xTasksWaitingToReceive;             /* Список задач, которые блокированы, ожидая 
                                               пока не произойдет чтение из этой очереди;
                                               Запомнены в порядке приоритета. */

  volatile unsigned portBASE_TYPE uxMessagesWaiting;  /* Количество элементов, имеющихся
                                                         в очереди в текущий момент. */
  unsigned portBASE_TYPE uxLength;                    /* Длина очереди, определяемая как 
                                                         количество элементов, находящихся в 
                                                         в очереди, а не как количество
                                                         байтов памяти, занимаемой очередью. */
  unsigned portBASE_TYPE uxItemSize;                  /* Размер каждого элемента, который 
                                                         хранится в очереди. */
                                         
} xQUEUE;

Это довольно стандартная очередь с указателями начала и конца, а также с указателями, позволяющими отслеживать, откуда мы только что выполнили чтение и куда мы только что сделали запись.

Когда создается очередь, пользователь указывает длину очереди и размер каждого элемента, который будет отслеживаться с помощью очереди. Указатели pcHead и pcTail используются для отслеживания того, как очередью используется внутренняя память. При добавлении элемента в очередь делается полная копия элемента во внутреннюю область хранения очереди.

Система FreeRTOS делает полную копию, а не хранит указатель на элемент, поскольку время жизни вставляемого элемента может быть намного короче, чем время жизни очереди. Рассмотрим, например, очередь из простых целых чисел, вставляемых и удаляемых с помощью локальных переменных в нескольких вызовах функций. Если в очереди хранятся указатели на целочисленные локальные переменные, то указатели станут недействительными как только целочисленные локальные переменные пропадут области видимости и память, используемая для локальных переменных, будет использована для некоторого нового значения.

Пользователь выбирает что следует помещать в очередь. Пользователь может помещать в очередь копии самих элементов, если элементы небольшие, например, как простые целые числа из предыдущего абзаца, либо пользователь может помещать в очередь указатели на элементы, если элементы большие. Обратите внимание, что в обоих случаях система FreeRTOS делает полную копию элементов: если пользователь выберет помещать в очередь копии элементов, то в очереди хранится полная копия каждого элемента, если пользователь выбирает помещать в очереди указатели, то в очереди хранится точная копия указателя. Конечно, если пользователь хранит в очереди указатели, то на него возлагается ответственность за управление памятью, связанной с указателями. Очереди безразлично, какие данные в ней хранятся, она просто должна знать размер каждого элемента данных.

В системе FreeRTOS поддерживаются вставки и удаления в очереди с блокировкой и без блокировки. Неблокирующие операции возвращают управление немедленно с указанием состояния «Вставлен элемент в очередь?» или «Удален элемент из очереди?». Блокирование указываются с тайм-аутом. Задача может ожидать снятие блокировки бесконечно или в течение ограниченного периода времени.

Заблокированная задача, назовем ее Задачей A, будет оставаться заблокированной до тех пор, пока не будет завершено выполнение операции вставки/удаления или пока не истечет (если оно установлено) время тайм-аута. Если прерывание или другая задача изменит очередь так, что может быть закончена операция для Задачи A, то Задача A будет разблокирована. Если операция в очереди, выполняемая для Задачи A можно выполнить в течение того, времени, пока задача выполняется, то Задача A завершит свою операцию с очередью и вернет значение состояния «success» (успешное завершение). Однако, в течение того времени, пока Задача A выполняется, может случиться так, что задача с более высоким приоритетом или прерывание выполнить еще одну операцию в очереди, которая помещает Задаче A выполнить свою операцию. В этом случае Задача A проверить значение тайм-аута и, если тайм-аут еще не истек, то продожит оставаться блокированной, либо вернет значение «failed» (не выполнено) в качестве состояния выполнения операции.

Важно отметить, что пока задача блокирована в очереди, остальная часть системы будет продолжать работать; другие задачи и прерывания будут продолжать выполняться. Таким образом, блокированная задача их не тратит ресурсы процессора, которые могут быть продуктивно использована другими задачами и прерываниями.

В системе FreeRTOS используется список xTasksWaitingToSend для отслеживания задач, которые блокированы при выполнении операции вставки элемента в очередь. Каждый раз, когда элемент удаляется из очереди, проверяется список xTasksWaitingToSend. Если задача находится в состянии ожидания в этом списке, то задача разблокируется.

Аналогично, с помощью списка xTasksWaitingToReceive отслеживаются задачи, которые блокируются при выполнении операции удаления из очереди. Каждый раз, когда новый элемент вставляется в очередь, проверяется список xTasksWaitingToReceive. Если задача находится в состоянии ожидания в этом списке, то задача разблокируется.

Семафоры и мютексы

Система FreeRTOS использует очереди для обмена данными между задачами и внутри задач. Система FreeRTOS также использует очереди для реализации семафоров и мютексов.

В чем разница?

Семафоры и мютексы могут рассматривать почти как одно и то же, но это не так. В системе FreeRTOS они реализуют аналогичным образом, но они предназначены для использования по-разному. Как они могут использоваться по-разному? Гуру встроенных систем Майкл Барр (Michael Barr) лучше всего это описывает в своей статье «Mutexes and Semaphores Demystified» («Демистификация мютексов и семафоров»):

Правильное использование семафора состоит в передаче сигнала от одной задачи в другую. Смысл мютексов в том, что каждая задача обращается к ним и отказывается от их использования в том порядке, в каком с их помощью устанавливается защита на совместно используемые ресурсы. В отличие от задач, в которых используются семафоры, может быть либо послан некоторый сигнал («send» или «отправить в терминах системы FreeRTOS), либо может происходить ожидание сигнала («receive» или «получить» в терминах системыFreeRTOS), но не оба варианта.

Мютекс используется для защиты общего ресурса. Задача включает мютекс, использует общий ресурс, а затем отключает мютекс. Никакая задача не может включить мютекс, пока мютекс включен другой задачей. Это гарантирует, что в каждый конкретный момент общим ресурсом может пользоваться только одна задача.

Семафоры, используемые некоторой задачей, посылают сигнал другой задаче. Процитирую статью Барра:

Например, в Задаче 1 может быть код, который, когда нажата кнопка «power» («Питание»), посылает сообщение (т.е. выдает сигнал или увеличивает на единицу некоторое значение) конкретному семафору, а Задача 2, которая включает дисплей, ожидает сигнала от того же самого семафора. В этом случае одна задача создает сигнал, а другая — его потребляет.

Если вам не все понятно с семафорами и мютексами, пожалуйста, ознакомьтесь со статьей Майкла.

Реализация

В системе FreeRTOS реализован N-элементный семафор в виде очереди, в которой может быть N элементов. В ней не хранятся какие-либо данные в виде элементов очереди; семафор просто следит за тем, сколько записей в текущий момент помещено в очередь, что осуществляется с помощью поля uxMessagesWaiting, имеющегося в очереди. Семафор реализует «чистую синхронизацию» так, как это названо в вызовах заголовочного файла semphr.h системы FreeRTOS. Поэтому размер элемента в очереди указан равным нулю байтов (uxItemSize == 0). Каждое обращение к семафору увеличивает или уменьшают на единицу значение поля uxMessagesWaiting; копирование элементов очереди или данных выполнять не требуется.

Точно также, как и семафоры, мютексы реализованы в виде очередей, но в них с помощью определений #defines перегружены несколько полей xQUEUE:

/* Перепределение полей структуры xQUEUE. */
#define uxQueueType           pcHead
#define pxMutexHolder         pcTail

Поскольку мютекс не хранит никаких данных в очереди, ему не нужна внутренняя память, и, поэтому, не нужны поля pcHead и pcTail. Система FreeRTOS устанавливает поле uxQueueType (в действительности поле pcHead) равным 0, указывая, что эта очередь используется для мютекса. Система FreeRTOS использует перегрузку полей pcTail для реализации в мютексах механизма наследования приоритетов.

В случае, если вы не знакомы с наследованием приоритетов, я для того, чтобы определить его, еще раз процитирую Майкла Барра, на этот раз его статью «Introduction to Priority Inversion» («Введение в инверсии приоритетов):

[Наследование приоритетов] разрешает чтобы задача с низким приоритетом наследовала приоритет любой задачи более высокого приоритета, ожидающей ресурс, которым эти задачи пользуются совместно. Такое изменение приоритета должно происходить сразу, как только задача с большим приоритетом переходит в состояние ожидания; оно должно прекращаться сразу, как только ресурс будет освобожден.

Система FreeRTOS реализует наследование приоритетов с использованием поля pxMutexHolder (которое, на самом деле, является перегруженным полем #define pcTail). Система FreeRTOS записывает задачу, которая использует мютекс, в поле pxMutexHolder. Когда будет обнаружено, что задача с более высоким приоритетом также пользуется мютексом, установленным задачей с низким приоритетом, система FreeRTOS «обновит» приоритет задачи с более низким приоритетом до приоритета задачи с более высоким приоритетом и будет его поддерживать таким до тех пор, пока первая задача не освободит ресурс.

3.7. Заключение

Мы завершили обзор архитектуры FreeRTOS. Надеюсь, теперь вы понимаете, как в системе FreeRTOS выполняются задачи и как между ними происходит взаимодействие. И если вы раньше никогда не заглядывали внутрь какой-нибудь ОС, то, я надеюсь, что теперь у вас есть общее представление о том, как они работают.

Очевидно, что в этой главе не рассмотрены все особенности архитектуры системы FreeRTOS. Следует отметить, что я не упомянул о выделении памяти, системе прерываний ISR, средствах отладки и о поддержке MPU. В этой главе также не обсуждалось, как настраивать и использовать систему FreeRTOS. Ричард Барри (Richard Barry) написал отличную книгу, Using the FreeRTOS Real Time Kernel: A Practical Guide («Использование ядра реального времени FreeRTOS: Практическое руководство»), в которой обсуждается именно это, я настоятельно ее рекомендую, если вы собираетесь пользоваться системой FreeRTOS.

3.8. Благодарности

Я хотел бы поблагодарить Ричарда Барри (Richard Barry) для создание и сопровождение системы FreeRTOS, а также за то, что он решил ее сделать проектом с открытым исходным кодом. Ричард очень помог при написании данной главы, рассказав немного об истории системы FreeRTOS, а также сделав замечания, очень ценные с технической точки зрения.

Спасибо также Эми Браун (Amy Brown) и Грегу Уилсону (Greg Wilso) за то, что они втянули меня в проект AOSA.

Последняя по упоминанию и наиболее важная благодарность моей супруге Саре за совместное участие в исследованиях и написании этой главы. К счастью, она знала, что я гик, когда выходила за меня замуж!

4.1. Цель разработки

Приложение GDB было спроектировано для использования в роли символьного отладчика для программ, разработанных с использованием таких компилируемых императивных языков программирования, как C, C++, Ada и Fortran. При использовании оригинального интерфейса командной строки приложения будет получен вывод, подобный следующему:

% gdb myprog
[...]
(gdb) break buggy_function
Breakpoint 1 at 0x12345678: file myprog.c, line 232.
(gdb) run 45 92
Starting program: myprog
Breakpoint 1, buggy_function (arg1=45, arg2=92) at myprog.c:232
232     result = positive_variable * arg1 + arg2;
(gdb) print positive_variable
$$1 = -34
(gdb)

GDB сообщает о том, что с отлаживаемым приложением что-то не так, разработчик говорит "ага" или "гммм", после чего ему предстоит дать ответы на вопросы о том, в чем заключается ошибка и как ее исправить.

Важная особенность архитектуры заключается в том, что подобный GDB инструмент при обобщенном рассмотрении является набором интерактивных инструментов для углубленного исследования программы и, следовательно, он должен иметь возможность ответить на серии непредсказуемых запросов. В дополнение к этому он будет использоваться для отладки оптимизированных средствами компилятора программ, а также программ, использующих каждую доступную аппаратную возможность для увеличения производительности, поэтому у него должна быть необходимая информация и возможность полноценной работы работы на низших системных уровнях.

У GDB также должна быть возможность отладки программ, скомпилированных с помощью сторонних компиляторов (не только компилятора GNU C), программ, скомпилированных давным-давно с помощью устаревших версий компиляторов, а также программ, информация о символах которых отсутствует, устарела или просто является некорректной; следовательно, еще одно архитектурное решение должно заключаться в том, что отладчик GDB должен продолжать работать и успешно выполнять поставленные перед ним задачи даже в том случае, если данные, относящиеся к внутреннему устройству приложения, отсутствуют, повреждены или просто некорректны.

В последующих разделах будут рассматриваться вопросы, подразумевающие наличие у читателя опыта использования интерфейса командной строки GDB. Если вы практически не знакомы с GDB, займитесь изучением руководства. [SPS+00].

4.2. Начало развития GDB

GDB является довольно старой программой. Он начал свое существование примерно в 1985 году как разработка от Richard Stollman, поставляемая вместе с GCC, GNU Emacs и другими ранними версиями компонентов проекта GNU. (В это время не существовало публичных репозиториев систем контроля версий, поэтому большая часть подробностей истории разработки на данный момент потеряна.)

При сравнении исходного кода ранних релизов от 1988 года с исходным кодом современных релизов можно найти всего лишь несколько похожих строк; практически весь исходный код GDB был переработан по крайней мере единожды. Другая заметная особенность ранних версий GDB заключается в достаточно скромных начальных целях разработки проекта, поэтому большая часть работы с того момента заключалась расширении возможностей приложения GDB для работы в различных окружениях и расширении вариантов его использования, что не предусматривалось изначальным планом разработки.

4.3. Блочная диаграмма

Обобщенная структура GDB
Рисунок 4.1: Обобщенная структура GDB

При отдаленном рассмотрении можно сделать вывод о том, что приложение GDB состоит из двух частей:

  1. Часть, ответственная за обработку символов, занимается работой с информацией о символах программы. Информация о символах включает имена функций и переменных, информацию о типах данных, номера строк, информацию об использовании машинных регистров и другую подобную информацию. В части, ответственной за обработку символов, происходит извлечение этой информации из исполняемого файла программы, разбор полученных выражений, поиск адресов в памяти для заданного номера строки, поиск в исходном коде и общая работа с программой по мере написания ее исходного кода разработчиком.
  2. Часть, ответственная за работу в целевой системе, занимается непосредственным взаимодействием с целевой системой. Она позволяет запустить и остановить исполнение программы, считать данные из памяти и регистров, изменить их, перехватить сигналы, а также выполнить другие аналогичные действия. Специфика реализации этих операций значительно отличается для различных систем; большинство Unix-подобных систем предоставляет специальный системный вызов с именем ptrace, который дает возможность одному процессу читать и записывать данные состояния другого процесса. Таким образом, часть GDB, ответственная за работу в целевой системе, в большинстве случаев использует системный вызов ptrace и интерпретирует получаемые результаты. Однако, в случае кросс-отладки встраиваемой системы, часть, ответственная за работу в целевой системе, формирует пакеты сообщений для отправки по сети и ожидает пакетов, отправленных в ответ.

Эти две части в каком-то смысле независимы друг от друга; вы можете исследовать код вашей программы, просматривать типы переменных, и.т.д., без непосредственного запуска программы. С другой стороны, в также можете заниматься отладкой необработанного машинного кода даже в том случае, если информация о символах приложения не доступна.

Объединяющим две рассмотренные части и находящимся посередине звеном является командный интерпретатор и главный управляющий цикл исполнения.

4.4. Примеры работы

В качестве простого примера взаимодействия описанных частей рассмотрим команду print, описанную выше. Командный интерпретатор ищет функцию, соответствующую команде print и предназначенную для разбора выражения и преобразования его в простую древовидную структуру, которая в последствии подвергнется обработке путем обхода дерева. В какой-то момент будет осуществлено обращение к таблице символов для установления того, что positive_variable является целочисленной глобальной переменной, которая хранится, скажем, по адресу 0x601028 в памяти. После этого осуществляется вызов функции из части, ответственной за работу в целевой системе, для чтения четырех байт из памяти по указанному адресу с последующей передачей этих байт функции форматированного вывода, которая выведет их в формате числа в десятичной системе счисления.

Для вывода исходного кода и его скомпилированной версии GDB осуществляет комбинацию операций чтения данных из файла исходного кода и целевой системы, после чего использует сгенерированную компилятором информацию о номере строки для объединения двух представлений. В приведенном здесь примере строка 232 имеет адрес 0x4004be, строка 233 находится по адресу 0x4004ce, и.т.д.

[...]
232  result = positive_variable * arg1 + arg2;
0x4004be <+10>:  mov  0x200b64(%rip),%eax  # 0x601028 <positive_variable>
0x4004c4 <+16>:  imul -0x14(%rbp),%eax
0x4004c8 <+20>:  add  -0x18(%rbp),%eax
0x4004cb <+23>:  mov  %eax,-0x4(%rbp)

233  return result;
0x4004ce <+26>:  mov  -0x4(%rbp),%eax
[...]

Команда пошаговой отладки step скрывает запутанные переходы, происходящие уровнем ниже. В момент, когда пользователь хочет перейти к следующей строке программы, части, ответственной за работу в целевой системе, отправляется запрос на выполнение единственной инструкции программы и повторной остановки (это одна из операций, которую можно выполнить с помощью вызова ptrace). После получения информации об остановке выполнения программы, GDB запрашивает значение из регистра программного счетчика (program counter - PC) (другая операция, выполняемая частью, ответственной за работу в целевой системе), после чего происходит сравнение этого значения с диапазоном адресов, который ассоциирован с данной строкой в части, ответственной за обработку символов. Если значение программного счетчика находится за пределами этого диапазона, GDB оставляет программу в остановленном состоянии, устанавливает новый номер строки исходного кода и сообщает об этом пользователю. Если же значение программного счетчика все еще находится в диапазоне адресов текущей строки, GDB переходит к выполнению следующей инструкции с последующим повторением проверки, повторяя эту последовательность действий до тех пор, пока программный счетчик не станет указывать на другую строку. Преимущество этого простого алгоритма заключается в том, что он всегда работает правильно независимо от того, имеются ли в строке переходы, вызовы подпрограмм, и.т.д., при этом он не требует от GDB интерпретации всех всех деталей, относящихся к используемому набору машинных инструкций. Недостаток же заключается в большом количестве взаимодействий с целевой системой в ходе каждого отдельного шага, что может привести к заметному замедлению отладки при использовании некоторых встраиваемых целевых систем.

4.5. Переносимость

Ввиду необходимости постоянного доступа к физическим регистрам чипа в процессе работы, приложение GDB изначально проектировалось с учетом возможности переноса на широкий спектр систем. Однако, стратегия переносимости приложения с течением времени значительно изменилась.

Изначально программа GDB разрабатывалась аналогично всем остальным программам проекта GNU того времени; для разработки использовалось ограниченное подмножество функций языка C, причем при написании кода применялась комбинация макросов препроцессора и фрагментов файла Makefile для адаптации к специфической архитектуре процессора и операционной системе. Несмотря на то, что главной задачей проекта GNU было создание самодостаточной "Операционной системы GNU", запуск приложений должен был быть возможен в множестве существующих на тот момент систем; разработка ядра Linux начнется только спустя несколько лет. Сценарий оболочки с именем configure является первым ключевым звеном процесса. Он может выполнять множество различных действий, таких, как создание символьной ссылки для специфичного для используемой системы файла, позволяющей использовать стандартное имя заголовочного файла или формирование файлов на основе фрагментов, а наиболее важная работа, заключающаяся в сборке программы, осуществлялась с использованием файла Makefile.

Такие программы, как GCC и GDB предъявляют большие требования к процессу переноса на другие системы в сравнении с такими программами, как cat или diff, поэтому со временем действия по переносу GDB на другие платформы были разделены на три класса, причем для выполнения каждого действия использовался свой фрагмент файла Makefile и свой заголовочный файл.

4.6. Структуры данных

Перед подробным рассмотрением составных частей приложения GDB, давайте рассмотрим основные структуры данных, с которыми работает GDB. Так как при разработке GDB используется язык программирования C, они реализуются в виде структур (struct) вместо объектов в стиле C++, но в большинстве случаев они рассматриваются как объекты, поэтому в данном случае мы также будем следовать практике разработчиков GDB и называть их объектами.

Точки останова

Точка останова является основным типом объекта, к которому пользователь может получить непосредственный доступ. Пользователь создает точку останова с помощью команды break, аргументы которой задают расположение (location), в качестве которого может использоваться имя функции, номер строки для исходного кода или адрес для машинного кода. GDB ставит в соответствие объекту точки останова небольшое целочисленное значение, которое пользователь может использовать впоследствии для работы с точкой останова. В рамках GDB точка останова представлена структурой языка C (struct) с множеством полей. Расположение точки останова преобразуется в машинный адрес, но при этом также сохраняется в оригинальном формате, ведь адрес может измениться, например, в том случае, когда программа повторно компилируется и загружается в рамках открытой сессии, поэтому требуется его повторный расчет.

Несколько типов объектов, а именно объекты точек наблюдения (watchpoints), точек перехвата (catchpoints) и точек трассировки (tracepoints), являющихся сходными по функциям с объектами точек останова, фактически используют структуру (struct) объектов точек останова. Это позволяет быть уверенным в том, что функции создания, изменения и удаления объектов постоянно будут находиться в работоспособном состоянии.

Термин "расположение" также относится к адресам в памяти, в которых будет установлена точка останова. В случаях использования inline-функций и шаблонов языка C++, можно столкнуться с ситуацией, когда единственная установленная пользователем точка останова может соответствовать нескольким адресам; например, каждая копия inline-функции требует отдельного расположения точки останова, которая была установлена в строке исходного кода тела этой функции.

Символы и таблицы символов

Таблицы символов относятся к ключевым структурам данных GDB и могут иметь достаточно большие размеры, иногда занимая по несколько гигабайт оперативной памяти. В некоторой степени такое поведение неизбежно; приложение больших размеров на языке C++ может содержать миллионы символов, при этом используя системные заголовочные файлы, которые в свою очередь также содержат миллионы дополнительных символов. Каждая локальная переменная, каждый именованный тип, каждый элемент перечисления - все это отдельные символы.

GDB использует множество обходных путей для сокращения объема таблиц символов, примерами которых являются такие обходные пути, как создание неполных таблиц символов (более подробно о них будет сказано позже), битовых полей в структурах (struct), и.т.д.

В дополнение к таблицам символов, которые по существу ставят символьные строки в соответствие адресам и данным о типах, GDB создает таблицы строк, с помощью которых поддерживается возможность перехода в двух направлениях; от номеров строк исходного кода к адресам в памяти и впоследствии от адресов памяти назад к строкам исходного кода. (Например, описанный ранее пошаговый алгоритм отладки не может работать без сопоставления адресов памяти и номеров строк исходного кода.)

Стековые фреймы

Процедурные языки программирования, для работы с которыми проектировался отладчик GDB, используют стандартную архитектуру времени исполнения, в рамках которой вызовы функций приводят к помещению программного счетчика в стек вместе с некоторой комбинацией из аргументов функций и локальных аргументов. Этот набор данных называется стековым фреймом (stack frame) или "фреймом" для краткости и в любой момент исполнения программы стек состоит из связанной последовательности фреймов. Подробности организации стекового фрейма значительно отличаются при переходе от использования одного чипа к другому, при этом они также зависят от используемых операционной системы, компилятора и параметров оптимизации.

Перенос GDB для использования при работе с новым чипом может потребовать разработки большого объема кода для анализа стека, так как программы (особенно при наличии ошибок, ведь именно в таких случаях пользователи наиболее часто запускают отладчик) могут быть остановлены в любом месте, причем фреймы могут быть неполными или частично перезаписанными самой программой. Что еще хуже, формирование стекового фрейма для каждого вызова функции замедляет приложение, поэтому проводящий хорошую оптимизацию компилятор при любом удобном случае упростит стековые фреймы или даже избавится от них, как это происходит при хвостовых вызовах.

Результат специфичного для чипа анализа стека, проводимого GDB, записывается путем формирования серий фреймовых объектов. Изначально GDB отслеживал фреймы, используя точное значение из регистра указателя фиксированных фреймов. Этот подход не позволял работать в случае применения вызовов inline-функций и других типов оптимизаций компилятора, поэтому в 2002 году разработчики GDB представили реализацию фреймовых объектов, которые записывают данные, полученные при исследовании каждого из фреймов, и связаны друг с другом, зеркалируя таким образом стековые фреймы приложения.

Выражения

Как и в случае с стековыми фреймами, GDB предполагает, что существует некая степень унификации всех поддерживаемых языков программирования и представляет все языки в виде древовидной структуры, созданной из узловых объектов. Набор типов узлов на самом деле является объединением всех типов выражений, свойственных всем различным языкам программирования; в отличие от компилятора, не существует причины, по которой пользователь не должен пытаться вычитать значение переменной языка Fortran из значения переменной языка C - при этом очевидно, что отличие значений этих переменных будет выражаться степенью числа два и в этот момент нам снова придется сказать "ага".

Значения

Результат вычисления сам по себе может быть более сложным, чем целочисленное значение или адрес в памяти, поэтому GDB также удерживает результаты вычисления в нумерованном списке истории, на который позднее могут даваться ссылки в выражениях. Для поддержания работоспособности этой системы, GDB использует структуру данных значений. Структуры значений (struct) имеют множество полей, хранящих различные свойства; важные свойства представлены в форме указаний на то, является ли значение правым (r-value) или левым (l-value) (по отношению к левым значениям могут использоваться операции присваивания в языке C), а также указаний на то, может ли значение вычисляться при необходимости.

4.7. Часть, ответственная за работу с символами

Часть GDB, отвечающая за работу с символами главным образом предназначена для чтения исполняемого файла, извлечения из него любой найденной информации о символах и добавления этой информации в таблицу символов.

Процесс чтения начинается с библиотеки BFD. BFD является разновидностью универсальной библиотеки для обработки бинарных файлов и файлов с объектным кодом; работая в любой системе, она может читать и записывать файлы в оригинальном формате Unix a.out, формате COFF (используемом в System V Unix и MS Windows), формате ELF (используемом в современных системах Unix, GNU/Linux и большинстве встраиваемых систем), а также в некоторых других форматах. Внутренне устройство этой библиотеки представлено сложной структурой макросов языка C, которые преобразуются в код, в котором объединяются запутанные особенности форматов фалов объектного кода для множества различных систем. Представленная в 1990 году, библиотека BFD также используется ассемблером и линковщиком GNU, а ее способность формирования файлов объектного кода для любой целевой системы делает ее ключевым программным компонентом при кроссплатформенной разработке с использованием инструментов, предоставляемых проектом GNU. (Перенос библиотеки BFD на другие платформы является также первым ключевым шагом процесса переноса набора инструментов на новую целевую платформу.)

GDB использует библиотеку BFD исключительно для чтения файлов, а именно для извлечения блоков данных из исполняемого файла и помещения их в пространство памяти процесса GDB. После этого GDB может использовать собственные функции чтения данных, разделенные на два уровня. Первый уровень позволяет получить базовую информацию о символах или "минимум данных для символов", который представлен только самими именами символов, которые требуются линковщику для выполнения работы. Эти имена являются всего лишь строками с адресами и ничем больше; будем считать, что адреса в текстовых секциях соответствуют функциям, адреса в секциях данных соответствуют данным, и так далее.

Второй уровень позволяет получить подробную информацию о символах, которая обычно имеет свой собственный формат, отличающийся от базового формата исполняемого файла; например, информация в формате данных отладки DWARF хранится в секциях файла формата ELF со специальными названиями. В отличие от нее, информация в старом формате данных отладки stabs, применяемом в Berkley Unix, использует специально обозначенные символы из основной таблицы символов.

Код, предназначенный для чтения информации о символах, является в какой-то мере сложным для чтения, так как различные форматы информации о символах позволяют кодировать любые разновидности информации о типах, которая в свою очередь будет использоваться в исходной программе, но в рамках каждого из форматов это действие осуществляется своим уникальным способом. Часть GDB, ответственная за чтение данных символов, просто обрабатывает данные определенного формата и формирует символы GDB, руководствуясь предположением об их соответствии в рамках используемого формата.

Частичные таблицы символов

Для программы значительного размера (такой, как Emacs или Firefox) формирование таблицы символов может занять достаточно длительный промежуток времени, возможно даже несколько минут. Измерения четко указывают на то, что это время тратится не на чтение фала, как кто-либо мог предположить, а на формирование таблицы символов в пространстве памяти GDB. В этом случае приходится обрабатывать буквально миллионы небольших взаимосвязанных объектов, что занимает время.

Доступ к большей части информации о символах никогда не будет осуществлен в рамках сессии, так как она является локальной для функций, которые пользователь скорее всего никогда не будет использовать. Таким образом, в тот момент, когда отладчик GDB в первый раз извлекает информацию о символах программы, он производит поверхностный обзор информации о символах и ищет исключительно глобально видимые символы, записывая их в таблицу символов. Информация о символах для функции или метода в полном объеме извлекается только тогда, когда пользователь останавливает выполнение программы в этой функции.

Частичные таблицы символов позволяют GDB стартовать в течение нескольких секунд, даже при работе с программами значительного размера. (Символы разделяемых библиотек также загружаются динамически, но процесс их загрузки кардинально отличается. Обычно GDB использует специфичную для используемой системы технику получения оповещения о загрузке библиотеки, после чего формирует таблицу символов с функциями и соответствующими им адресами, полученными от динамического линковщика.)

Поддержка языков программирования

Поддержка языков программирования в основном заключается в реализации системы разбора выражений и вывода значений. Детали реализации системы разбора выражений зависят от языка программирования, но в общем случае она базируется на системе анализа грамматики Yacc, взаимодействующей со специально разработанным лексическим анализатором. В соответствии с целями разработки GDB, заключающимися в обеспечении гибкости при работе в интерактивном режиме, система разбора выражений не должна быть особенно строгой; например, если она сможет определить подходящий тип данных для выражения, она просто будет рассматривать случай использования этого типа без предъявления пользователю требования, заключающегося в необходимости добавления операции приведения или преобразования типа.

Так как система разбора выражений не должна обрабатывать объявления или декларации типов, она гораздо проще полноценной системы разбора выражений языка программирования. Аналогично, в случае вывода значений приходится иметь дело с множеством типов значений, которые следует выводить и обычно специфичная для языка программирования функция вывода данных может вызвать необходимый код для непосредственного выполнения задачи.

4.8. Часть, ответственная за работу в целевой системе

Часть, ответственная за работу в целевой системе, занимается управлением процессом исполнения программы и обрабатывает данные. В некотором смысле эта часть приложения GDB является полноценным низкоуровневым отладчиком; если вам хватает возможностей пошагового исполнения инструкций и создания необрабатываемых дампов памяти приложения, вы можете работать с GDB вообще без использования таблиц символов. (Так или иначе, вы можете оказаться в таком положении в том случае, если при выполнении функции библиотеки, символы которой были удалены из бинарного файла, исполнение программы будет остановлено.)

Векторы и стек целевой системы

Изначально часть GDB, ответственная за работу на целевой системе, была сформирована из набора специфичных для платформы файлов исходного кода, в рамках которых были реализованы алгоритмы вызова ptrace, запуска исполняемых файлов, и выполнения других подобных функций. Это решение не было достаточно гибким для использования при работе в рамках долговременных сессий отладки, когда пользователь может перейти от непосредственной к удаленной отладке, переключиться с отладки исполняемых файлов на анализ дампов памяти, после чего перейти к отладке работающих программ, подключать и отключать отладчик и выполнять другие аналогичные действия, поэтому в 1990 году John Gilmore провел повторное проектирование части GDB, ответственной за работу в целевой системе, направленное на выполнение всех специфичных для целевой системы операций посредством вектора целевой системы (target vector), являющегося по существу классом, на основе которого создаются объекты, каждый из которых описывает специфику типа целевой системы. Каждый вектор целевой системы реализован в форме структуры, состоящей из нескольких множеств указателей функций (обычно называемых "методами"), назначение которых варьируется от чтения и записи данных памяти и регистров до возобновления исполнения программы и установления параметров обработки разделяемых библиотек. В GDB существует около 40 векторов целевых систем, распределенных в диапазоне от часто используемого вектора целевой системы для Linux до малоизвестных векторов целевых систем, таких, как вектор для управления Xilinx MicroBlaze. Код поддержки дампов памяти использует вектор целевой системы, который получает данные путем чтения файла дампа памяти, при этом существует другой вектор целевой системы, читающий данные из исполняемого файла.

Обычно оказывается полезным смешивать методы нескольких векторов целевых систем. Представим случай вывода значения инициализированной глобальной переменной в Unix; перед началом исполнения программы вывод значения переменной будет работать, но в этот момент не будет процесса для чтения данных, поэтому байты данных должны быть получены из секции .data исполняемого файла. Таким образом, GDB использует векторы целевых систем для чтения исполняемых файлов и читает необходимые данные из бинарного файла. Но в процессе работы программы байты данных должны быть получены из адресного пространства процесса. Следовательно, в рамках GDB реализован "стек векторов целевых систем", в котором вектор целевой системы для работы с выполняющимися процессами помещается выше вектора для чтения данных из исполняемого файла при запуске процесса и перемещается вниз после завершения его работы.

В реальности стек целевых систем не обладает всеми ожидаемыми свойствами. Векторы целевых систем не являются действительно ортогональными друг другу; если вы работаете и с исполняемым файлом, и с выполняющимся процессом в рамках сессии, хотя и есть смысл отдавать предпочтение методам для работы с выполняющимся процессом, а не методам для работы с исполняемым файлом, практически никогда не имеет смысла делать наоборот. Поэтому в результате разработчики GDB ввели нотацию слоев (stratum), в рамках которой векторы целевых систем для работы с процессами объединяются в один слой, а векторы для работы с файлами объединяются в слой, находящийся ниже предыдущего, при этом векторы целевых систем могут добавляться наряду со вставкой и извлечением их из стека.

(Несмотря на то, что мэйнтейнеры GDB недолюбливают реализацию стека целевых систем, никто не предложил и не создал прототип более совершенной альтернативы.)

Gdbarch

Являясь программой, работающей напрямую с инструкциями центрального процессора, GDB требуется подробная информация об особенностях устройства чипа. Требуется информация о регистрах, размерах различных типов данных, размере и виде адресного пространства, используемом соглашении о вызовах функций, инструкции, которая будет вызывать исключение ловушки, и.т.д. Код на языке C для работы с этой информацией в GDB обычно занимает от 1,000 до 10,000 строк в зависимости от сложности архитектуры.

Изначально работа с этой информацией осуществлялась путем использования специфичных для целевой платформы макросов препроцессора, но по мере усложнения отладчика макросы все больше увеличивались в размере и с течением времени длинные макроопределения были преобразованы в обычные функции языка C, вызываемые из макросов. Хотя это преобразование и было полезным, оно не могло применяться для различных вариантов архитектуры (ARM и ARM Thumb, 32-битная и 64-битная версии MIPS или x86, и.т.д.) и, что еще хуже, скоро должны были появиться многоархитектурные системы, в случае использования которых макросы не работали бы вообще. В 1995 году я предложил решение этой проблемы, заключающееся в использовании архитектуры на основе объектов и с 1998 года компания Cygnus Solutions начала финансирование работы Andrew Cagney, заключающейся в начальной реализации предложенных изменений. (Компания Cygnus Solutions была создана в 1989 году для коммерческой поддержки свободного программного обеспечения и в 2000 году была приобретена компанией RedHat.) Для завершения работы потребовалось несколько лет, причем в код также были внесены изменения от множества сторонних разработчиков и в итоге в ходе работы было изменено около 80,000 строк кода.

Новые реализованные конструкции стали называть объектами gdbarch и в данный момент эти объекты могут содержать до 130 методов и переменных, описывающих целевую архитектуру, но, несмотря на это, простая целевая архитектура может потребовать использования только части этих методов и переменных.

Для получения представления о том, чем отличаются старый и новый методы, рассмотрим объявление размера для типа данных long double архитектуры x86, равного 96 битам, из файла gdb/config/i386/tm-i386.h от 2002 года:

#define TARGET_LONG_DOUBLE_BIT 96

и из файла gdb/i386-tdep.c от 2012 года:

i386_gdbarch_init( [...] )
{
  [...]

  set_gdbarch_long_double_bit (gdbarch, 96);

  [...]
}

Управление исполнением

Сердцем GDB является цикл управления исполнением. Мы затрагивали его ранее при описании одиночных построчных переходов; алгоритм выполнял обход множества инструкций до момента нахождения инструкции, ассоциированной с другой строкой исходного кода. Этот цикл обхода называется wait_for_inferior или "wfi" для краткости.

Концептуально он находится внутри главного цикла обработки команд и используется только при вводе команд, которые направлены на продолжение исполнения программы. В момент, когда пользователь вводит команды continue или step и ожидает, не замечая никаких изменений, отладчик GDB на самом деле может быть загружен работой. В дополнение к описанному выше циклу пошагового перехода, программа может столкнуться с инструкциями-ловушками и сообщить об исключении GDB. Если исключение было сгенерировано из-за установки точки останова средствами GDB, производится проверка условия установки точки останова и в том случае, если условие не выполняется, ловушка убирается, осуществляется шаг по направлению к исходной инструкции, повторная установка ловушки, после чего выполнение программы возобновляется. Аналогично, в случае генерации сигнала GDB может принять решение о том, следует ли игнорировать его или произвести обработку одним из заранее заданных способов.

Все эти действия происходят под управлением цикла wait_for_inferior. Изначально это был простой цикл, ожидающий остановки выполнения программы на целевой системе и впоследствии принимающий решение о том, что сделать с полученными данными, но по мере переноса на различные системы потребовалась реализация специальных методов обработки данных и этот цикл увеличился в размере до тысячи строк, причем в нем по непонятным причинам начали применяться переходы goto. Например, с распространением вариантов систем Unix, не находилось понимающих тонкости их работы людей и не было возможности использования этих систем для тестирования и поиска регрессий, поэтому появилось обоснованное желание, заключающееся в осуществлении преобразования кода таким образом, чтобы не было изменено поведение приложения при работе с системами, на которые уже был осуществлен перенос, а переход через части цикла с помощью операторов goto был в данном случае самой простой тактикой.

Единственный большой цикл также был проблемой для асинхронной обработки или отладки многопоточных программ, при работе с которыми пользователь может изъявить желание запускать или останавливать отдельные потоки, причем в этом случае остальные потоки программы должны были выполняться.

Преобразование к ориентированной на события модели заняло несколько лет. Я разделил цикл wait_for_interior на части в 1999 году, представив структуру управления состоянием выполнения приложения, предназначенную для замены набора локальных и глобальных переменных и преобразования беспорядочных переходов в небольшие независимые друг от друга функции. В то же время Elena Zanonni и другие разработчики представили очереди событий, которые позволяли получать и пользовательский ввод, и уведомления от цикла.

Протокол удаленной отладки

Несмотря на то, что векторы целевых архитектур GDB позволяют реализовать множество различных методов для управления процессом исполнения программы на удаленном компьютере, для этой цели у нас есть отдельный предпочтительный протокол. Для него не придумано определенного названия, поэтому для обозначения обычно используются термины: "удаленный протокол" ("remote protocol"), "удаленный протокол GDB" ("GDB remote protocol"), "удаленный последовательный протокол" (также используется аббревиатура "RSP", расшифровывающаяся как "remote serial protocol"), "удаленный протокол .c" ("remote.c protocol", в соответствии с расширением файла исходного кода с его реализацией) или иногда "протокол-заглушка" ("stub protocol") для указания на то, что реализация протокола осуществлена на целевой системе.

Основной протокол достаточно прост и отражает желание его реализации для работы с небольшими встраиваемыми системами 1980-х годов, объемы памяти которых измерялись в килобайтах. Например, в рамках протокола пакет $g запрашивает данные всех регистров и ожидает ответа, содержащего все байты данных из этих регистров, в итоге передаются все данные - предполагается, что количество регистров, их размер и порядок следования данных совпадают с используемыми в рамках проекта GDB соглашениями.

Протокол ожидает одиночного ответа на каждый отправленный пакет и предполагает, что соединение является надежным, добавляя контрольные суммы только к отправляемым пакетам (таким образом, пакет $g при отправке по сети на самом деле будет выглядеть как $g#67).

Несмотря на то, что существует только ограниченное количество обязательных типов пакетов (соответствующих половине методов вектора целевой системы, которые наиболее важны), по прошествии многих лет было добавлено большое количество дополнительных, необязательных пакетов для поддержки всех функций, начиная с функций для работы с аппаратными точками останова и заканчивая функциями для работы с точками трассировки и разделяемыми библиотеками.

На самой же целевой системе реализация удаленного протокола может быть представлена в широком диапазоне форм. Протокол в полной мере документирован в руководстве GDB и это значит, что имеется возможность создания реализации, которая не будет обременена условиями лицензии GNU и на самом деле многие производители оборудования уже реализовали код, который позволяет работать с удаленным протоколом GDB как во время лабораторных испытаний, так и во время эксплуатации оборудования. Система IOS компании Cisco, под управлением которой работает большая часть выпускаемого этой компанией сетевого оборудования, является широко известным примером.

Реализация протокола на целевой системе обычно называется "отладочная заглушка" или просто "заглушка" для того, чтобы подчеркнуть тот факт, что она выполняет не так много работы самостоятельно. Исходные коды GDB содержат несколько примеров реализации подобных заглушек, которые обычно реализуются в форме примерно 1,000 строк низкоуровневого исходного кода на языке C. На совершенно новой системе вез установленной ОС код заглушки должен установить собственные обработчики для перехвата аппаратных исключений и, что более важно, для перехвата инструкций-ловушек. Также потребуется драйвер последовательного порта, если сеть организуется посредством последовательного соединения. Сама работа протокола достаточно проста, так как все необходимые пакеты содержат одиночные символы, которые могут быть декодированы при помощи оператора switch.

Другим подходом к реализации удаленного протокола является создание "спрайта" для обмена данными между копией GDB и удаленным отлаживаемым аппаратным обеспечением, представленным устройствами JTAG, устройствами "wigglers" для интерфейса JTAG, и.т.д. Обычно эти устройства имеют библиотеку, которая может использоваться на компьютере, физически соединенным с целевым устройством, при этом обычно API библиотеки не совместим в внутренней архитектурой GDB. Таким образом, хотя и существуют примеры непосредственного вызова функций библиотек из GDB, наиболее простым зарекомендовавшим себя способом является запуск спрайта в качестве независимой программы, которая понимает удаленный протокол и преобразует пакеты в вызовы функций предназначенной для работы с устройством библиотеки.

GDBServer

Исходные коды GDB включают в свой состав одну завершенную и работоспособную реализацию удаленного протокола для использования на целевой системе: GDBserver. GDBserver является скомпилированной программой, выполняющейся в целевой операционной системе и осуществляющей контроль над другими выполняющимися в целевой операционной системе программами, которая использует возможности непосредственной отладки в ответ на пакеты, полученные с применением удаленного протокола. Другими словами, эта программа выступает в роли специфического прокси-сервера для непосредственной отладки.

GDBserver не выполняет каких-либо действий, которые не может выполнять сам отладчик GDB; если в вашей целевой системе может выполняться GDBserver, то теоретически в ней сможет работать и GDB. Однако, приложение GDBserver в 10 раз меньше и ему не требуется осуществлять управление таблицами символов, поэтому оно очень хорошо подходит для работы со встраиваемыми системами на основе GNU/Linux, а также с другими подобными системами.

GDBserver
Рисунок 4.2: GDBserver

Приложения GDB и GDBserver частично используют общий код, но хотя идея инкапсуляции специфичных для ОС функций контроля над процессами и является очевидной, существуют сложности, связанные с разделением подразумеваемых зависимостей в рамках GDB и процесс разделения идет медленно.

4.9. Интерфейсы GDB

По своей сути GDB является отладчиком с интерфейсом командной строки. В течение долгого времени люди пробовали различные схемы для реализации на его основе графического многооконного интерфейса отладчика, но, несмотря на все потраченное время и приложенные усилия, ни один из этих интерфейсов так и не стал общепринятым.

Интерфейс командной строки

Интерфейс командной строки использует стандартную библиотеку readline проекта GNU для посимвольной обработки пользовательского ввода. Библиотека readline заботится о таких вещах, как редактирование строк и завершение команд; пользователь может выполнять такие действия, как использование клавиш перемещения курсора для перехода к предыдущей строке и исправления символа.

После этого GDB принимает возвращаемую библиотекой readline команду и производит ее поиск в каскадной структуре таблиц команд, в которой при каждом успешном обнаружении управляющего слова производится выбор дополнительной таблицы. Например, команда set print elements 80 затрагивает три таблицы; первой является таблица всех доступных команд, второй - таблица параметров команды set, а третьей - таблица параметров вывода значений, в соответствии с которой elements является одним из ограничений, налагаемых на число объектов, выводимых при рассмотрении таких конструкций, как строка или массив. После того, как с помощью каскадной структуры была вызвана соответствующая функция для обработки команды, она получает контроль над процессом выполнения команды и обработка аргументов является исключительно ее задачей. Некоторые команды, такие, как run обрабатывают свои аргументы по аналогии со стандартом argc/argv, традиционным для языка C, в то время, как другие команды, такие, как print предполагают, что оставшаяся часть строки является единым выражением языка программирования и передают всю строку специфичной для языка программирования системе разбора синтаксических конструкций.

Машинный интерфейс

Одним способом реализации графического интерфейса для отладки является использование GDB в роли "обработчика команд" реализующей графический интерфейс программы, которая занимается преобразованием событий нажатия кнопок мыши в команды и форматированием выводимых результатов для их показа в окнах. Описанный подход использовался несколько раз в рамках таких приложений, как KDbg и DDD (Data Dispaly Debugger), но он не был идеальным по той причине, что иногда результаты форматировались для удобства чтения человеком, причем в этих случаях отбрасывались подробности выполнения команды и делалась ставка на возможность человека мыслить в определенном контексте.

Для решения этой проблемы в GDB реализован альтернативный "пользовательский" интерфейс, известный как машинный интерфейс (Machine Interface или MI для краткости). По своей сути это еще один интерфейс командной строки, но и команды и результаты их выполнения в данном случае используют дополнительные синтаксические конструкции для явного указания их особенностей - каждый аргумент помещен в кавычки и сложные выводимые структуры содержат разделители для подгрупп и имен параметров частей их компонентов. В дополнение команды машинного интерфейса могут использовать префиксы с идентификаторами последовательности, которые будут повторяться при выводе результатов выполнения команд для гарантии того, что результаты выполнения команд будут поставлены в соответствие правильным командам.

Для того, чтобы ознакомится с различиями двух форм вывода данных ниже приведен пример использования обычной команды step и ответ GDB:

(gdb) step

buggy_function (arg1=45, arg2=92) at ex.c:232
232  result = positive_variable * arg1 + arg2;

При использовании машинного интерфейса ввод и вывод содержит больше подробностей, причем данные оформлены таким образом, чтобы сторонне программное обеспечение имело возможность более точно произвести их разбор:

4321-exec-step

4321^done,reason="end-stepping-range",
      frame={addr="0x00000000004004be",
             func="buggy_function",
             args=[{name="arg1",value="45"},
                   {name="arg2",value="92"}],
             file="ex.c",
             fullname="/home/sshebs/ex.c",
             line="232"}

Окружение разработки Eclipse [ecl12] является наиболее известным примером клиента машинного интерфейса.

Другие пользовательские интерфейсы

Дополнительные пользовательские интерфейсы представлены версией на основе tcl/tk с названием GDBtk или Insight, а также версией на основе curses с названием TUI, изначально реализованной компанией Hewlett-Packard. GDBtk является обычным многопанельным графическим интерфейсом, созданным с использованием библиотеки tk, в то время, как TUI является интерфейсом, созданным путем разделения областей текстового вывода на экране.

4.10. Процесс разработки

Мэйнтейнеры

Как и в случае любой оригинальной программы проекта GNU, процесс разработки GDB следует "соборной" модели. Изначально разработанное Richard Stallman, приложение GDB прошло ряд "мэйнтейнеров", каждый из которых был по совместительству архитектором, исследователем патчей и ответственным за выпуск релизов лицом, имеющим доступ к репозиторию исходного кода, который предоставлялся только ограниченному числу работников компании Cygnus.

В 1999 году проект GDB мигрировал в публичный репозиторий исходного кода, при этом команда мэйнтейнеров увеличилась до нескольких десятков человек, которым помогали сторонние разработчики, наделенные привилегиями внесения изменений в исходный код. Это изменение процесса разработки позволило значительно ускорить его, увеличив количество еженедельных изменений в исходном коде с 10 до 100 и более.

Тестирование, тестирование

Так как GDB является системо-зависимым приложением, перенесенным на множество систем в диапазоне от очень малых до очень больших и поддерживающим сотни команд, параметров и стилей использования, даже для опытного разработчика GDB достаточно сложно предвидеть все эффекты, вызванные изменениями в коде.

Для этой цели и предназначен набор тестов. Набор тестов состоит из множества тестовых программ, комбинируемых со сценариями инструмента тестирования expect, использующими фреймворк тестирования на основе tcl с названием DejaGNU. Базовая модель тестирования основана на том, что каждый сценарий использует отладчик GDB таким образом, как это делается при отладке программы, отправляя команды и после их выполнения сравнивая вывод с шаблонами с использованием регулярных выражений.

Набор тестов также поддерживает возможность запуска кросс-отладки как для работающего аппаратного обеспечения, так и для симуляторов, а также позволяет использовать тесты, специфичные для отдельной архитектуры или конфигурации.

На конец 2011 года набор тестов состоит из 18,000 тестов, представленных тестами базовых функций, тестами, специфичными для языков программирования, тестами, специфичными для архитектур, а также тестами машинного интерфейса. Большая част этих тестов представлена обобщенными тестами, которые могут выполняться при любой конфигурации. Лицам, вносящим изменения в код GDB, следует выполнить тесты из набора по отношению к измененному исходному коду и убедиться в отсутствии регрессий, причем для каждой новой возможности должны реализоваться новые тесты. Однако, из-за того, что ни у кого нет доступа ко всем платформам, которые могут быть затронуты при внесении изменения в исходный код, очень редко удается достичь полного отсутствия неудачно завершенных тестов; наличие 10-20 неудачно завершенных тестов обычно вполне допустимо для создания копии исходного кода, предназначенной для непосредственной отладки, при этом для некоторых встраиваемых систем количество неудачно завершенных тестов может быть большим.

4.11. Выученные уроки

Открытый процесс разработки является выигрышным решением

Приложение GDB изначально являлось экземпляром программного обеспечения, созданного в процессе применения "соборной" модели разработки, в рамках которой мэйнтейнер осуществляет непосредственное управление исходным кодом, а весь остальной мир наблюдает за прогрессом разработки только при периодической публикации копий исходного кода. Применение такой модели обосновывалось относительным непостоянством в отправке патчей, но на самом деле закрытый процесс разработки сам по себе препятствовал созданию патчей. После перехода к открытому процессу разработки количество патчей стало таким большим, каким не было никогда ранее, причем качество кода осталось таким же высоким, как и было ранее, а в некоторых случаях повысилось.

Создавайте план, но ожидайте его изменения

Процесс разработки приложения с открытым исходным кодом по своей сути является хаотичным, так как отдельные лица в течение определенного промежутка времени работают над кодом, после чего прекращают работу, возлагая обязанности по продолжению разработки на других.

Однако, в данном случае все еще уместно создавать план разработки и публиковать его. Он может помочь в координации действий разработчиков в том случае, ели они работают над реализацией взаимосвязанных возможностей, он может быть показан потенциальным спонсорам, а также он может помочь желающим поучаствовать в развитии проекта лицам в размышлениях о том, чем именно им стоит заняться.

При составлении плана не следует обозначать точные даты и промежутки времени; даже в том случае, если кто-либо проявляет энтузиазм при работе в определенном направлении, очень маловероятно то, что люди смогут гарантировать работу в течение полного рабочего дня в течение такого длительного промежутка времени, который позволит завершить работу до выбранной даты.

По этой причине не нужно цепляться за план в случае истечения указанных в нем сроков. В долговременной перспективе в рамках разработки проекта GDB был составлен план реструктуризации приложения и выделения библиотеки libgdb с четко заданным API, которая могла бы связываться со сторонними приложениями (в особенности с приложениями, реализующими графические интерфейсы); при этом даже был изменен процесс сборки с целью компиляции библиотеки libgdb.a на промежуточном шаге. Несмотря на то, что идея о создании библиотеки периодически озвучивалась впоследствии, преимущества интегрированной среды разработки Eclipse и машинного интерфейса отодвинули обоснование необходимости создания библиотеки на второй план, а в январе 2012 года мы отвергли концепцию библиотеки и на данный момент занимаемся удалением теперь уже не нужных фрагментов кода.

Все было бы идеально, если бы мы обладали незаурядным умом

После обзора некоторых произведенных нами изменений, вы можете подумать: "Почему с самого начала все не было сделано правильно?" Ну, мы были просто не достаточно умны.

Конечно же, мы могли ожидать, что отладчик GDB станет чрезвычайно популярным и будет перенесен на десятки и десятки архитектур для осуществления и непосредственной и удаленной отладки. Если бы мы знали это, мы начали бы разработку с создания объектов gdbarch вместо усовершенствования старых макроопределений и глобальных переменных в течение нескольких лет; то же самое можно сказать и о векторах целевых систем.

Конечно же, мы могли догадываться о том, что GDB будет использоваться с графическими интерфейсами. Кроме того, в 1986 году как Mac, так и X Window System уже существовали в течение двух лет! Вместо проектирования традиционного интерфейса командной строки, мы могли создать интерфейс для асинхронной обработки событий.

При этом в реальности урок заключался не в том, что разработчики GDB были глупы, а в том, что мы, возможно, не были достаточно прозорливы и не имели представления о том, как должен развиваться проект GDB. В 1986 году вообще не было очевидным тот факт, что использующий мышь оконный интерфейс начнет применяться повсеместно; в том случае, если бы первая версия GDB была в совершенстве адаптирована для использования с графическим интерфейсом, мы бы выглядели как гении, но это была бы всего лишь случайная удача. Вместо этого, улучшая работу GDB в одной ограниченной области, мы сформировали пользовательскую базу, которая позволила нам в будущем увеличить темпы разработки и повторного проектирования.

Следует учиться жить с незавершенными переходами к использованию новых технологий

Пытайтесь довести до конца переход к использованию новых технологий, хотя этот процесс и может занять некоторое время; будьте готовы работать в условиях незавершенных переходов.

На саммите разработчиков GCC в 2003 году Zack Weinberg сожалел о "незавершенных переходах к использованию новых технологий" в рамках проекта GCC, при осуществлении которых новая инфраструктура была введена в строй в то время, как старая инфраструктура не могла быть свернута. В проекте GDB тоже присутствуют такие переходы, но в то же время мы можем привести в пример несколько переходов, которые были успешно завершены, таких, как переход к использованию векторов целевых систем и переход к использованию gdbarch. Несмотря на это, для их реализации потребовалось несколько лет и в течение этого времени приходилось поддерживать отладчик в работоспособном состоянии.

Старайтесь не сильно привязываться к коду

В ситуации, когда вы в течение длительного промежутка времени работаете с отдельным фрагментом кода, причем вы работаете с важной программой, достаточно легко привязаться к этому коду, причем в этом случае вы будете даже формировать свои мысли в соответствии с этим кодом, а не наоборот.

Старайтесь избегать таких ситуаций.

Любой фрагмент кода создавался на основе осознанных решений: некоторые из решений приняты в большей степени под впечатлением от чего-либо, некоторые - в меньшей степени. Умное принятое в 1991 году решение, заключающееся в уменьшении объема используемой оперативной памяти, является лишим усложнением кода в 2011 году, когда вполне доступны объемы оперативной памяти, исчисляющиеся гигабайтами.

Когда-то отладчик GDB мог работать с суперкомпьютером Gould. Когда примерно в 2000 году была выведена из строя последняя подобная машина, пропал весь смысл поддержки этой системы. Данный эпизод является иллюстрацией процесса удаления кода из состава GDB, причем на сегодняшний день при выпуске большинства релизов та или иная часть кода считается устаревшей и удаляется.

Фактически существует список радикальных изменений, которые уже запланированы или реализуются и варьируются от применения языка Python для реализации сценариев до поддержки отладки приложений на многопроцессорных системах с высокой степенью параллелизма и перехода к использованию языка программирования C++. Для реализации этих изменений могут потребоваться годы; поэтому у нас есть еще больше оснований для начала работы над их реализацией прямо сейчас.

5.1. Что такое язык Haskell?

Язык Haskell является функциональным языком программирования, который определен согласно документу, известному как «Haskell Report» («Отчет по языку Haskell»), последней версией которого является документ «Haskell 2010» [Mar10]. Язык Haskell был создан в 1990 году несколькими представителями академического научно-исследовательского сообщества, заинтересованного в функциональных языках, с целью решить проблему отсутствия общего языка, который можно было бы использовать как субъект для своих исследований.

Две особенности языка Haskell выделяют его из множества других языков программирования:

Язык Haskell также является строго типизированным (strongly-typed), хотя он и поддерживает создание производных типов (type inference), что означает, что аннотации типов редко бывают необходимы.

Те, кому интересна подробная история языка Haskell, могут прочитать ее в [HHPW07].

5.2. Общий взгляд на проект

На самом высоком уровне, компилятор GHC можно разделить на следующие три отдельные части

По сути, эти три отдельные части в точности соответствуют трем подкаталогам дерева исходных кодов компилятора GHC: compiler, libraries и rts, соответственно

Мы здесь не будем тратить много времени на обсуждение загрузочных библиотек, поскольку они с архитектурной точки зрения малоинтересны. Все ключевые проектные решения воплощены в компиляторе и системе времени выполнения, поэтому мы посвятим оставшуюся часть этой главы обсуждению этих двух компонентов.

Оцениваем размер кода

В последний раз мы подсчитывали количество строк в компиляторе GHC в 1992 году — см. «The Glasgow Haskell compiler: a technical overview» («Компилятор Glasgow Haskell: технический обзор»), обзор на конференции JFIT, 1992, поэтому интересно посмотреть, как с тех пор все изменилось. На рис.5.1 приведены данные о количестве строк кода в компиляторе GHC в его основных компонентах; текущие значения сравниваются с теми, что были в 1992 году.

МодульСтроки (1992 г.)Строки (2011 г.)Увеличение

Синтаксический анализ

1055

4098

3,9

Модуль переименований

2828

4630

1,6

Проверка типов

3352

24097

7,2

Сокращение синтаксического разнообразия языка

1381

7091

5,1

Основные преобразования

1631

9480

5,8

Преобразования STG

814

840

1

Распараллеливания данных в языке Haskell

3718

Генерация кода

2913

11003

3,8

Генерация нативного кода

14138

Генерация кода LLVM

2266

Интерактивная среда GHCi

7474

Абстрактный синтаксис языка Haskell

2546

3700

1,5

Ядро языка

1075

4798

4,5

Язык STG

517

693

1,3

Язык C-- (был язык Abstract C)

1416

7591

5,4

Представление идентификаторов

1831

3120

1,7

Представление типов

1628

3808

2,3

Прелюдия определений

3111

269

0,9

Утилиты

1989

7878

3,96

Профилирование

191

367

1,92

Всего в компиляторе

28275

139955

4,9

 

Система времени выполнения

Весь код на языках C и C--

43865

48450

1,10

Рис.5.1: Количество строк в компиляторе GHC, прошлое и настоящее

В этих цифрах есть несколько примечательных особенностей:

Компилятор

Мы можем выделить в компиляторе следующие три части:

Компилятор является не просто исполняемым файлом, который выполняет эти функции; он сам является библиотекой с большим интерфейсом API, который можно использовать для создания других инструментальных средств, работающих с исходным кодом на языке Haskell, например, среды разработки IDE и аналитических инструментальных средств.

Компиляция кода на языке Haskell

Как и в большинстве компиляторов, компиляция файла с исходным кода на языке Haskell выполняется как последовательность фаз, причем результат работы каждой фазы передается на вход следующей фазы. Общая структура различных фаз показана на рис.5.2.

Рис.5.2: Фазы работы компилятора (картинка кликабельна)

Синтаксический разбор

Мы начинаем в традиционном стиле с синтаксического разбора, при котором в качестве входного берется файл с исходным кодом на языке Haskell, а на выходе создается абстрактный синтаксис. В компиляторе GHC тип данных абстрактного синтаксиса HsSyn параметризуется при помощи типов идентификаторов, которые в нем есть, с тем, чтобы для некоторого типа идентификаторов t дерево абстрактного синтаксиса имело тип HsSyn t. Это позволяет добавлять дополнительную информацию к идентификаторам по мере того, как программа проходит через различные фазы компилятора, и одновременно повторно использовать один и тот же тип деревьев абстрактного синтаксиса.

Результатом выполнения синтаксического разбора является абстрактное синтаксическое дерево, в котором идентификаторы являются простыми строками, которые мы называем RdrName. Таким образом, абстрактный синтаксис, создаваемый парсером, имеет тип HsSyn RdrName.

В компиляторе GHC используются инструментальные модули Alex и Happy для генерации кода при лексическом и синтаксическом анализе, соответственно, которые являются аналогами инструментальных средств lex и yacc для языка C.

Парсер компилятора GHC является чисто функциональным. В действительности, интерфейс API из библиотека GHC предоставляет чистую функцию, называемую parser, которая берет строку String (и некоторые другие данные) и возвращает либо разобранный абстрактный синтаксис, либо сообщение об ошибке.

Переименование

Переименование является процессом разрешения (присвоения новых имен — прим.пер.) всех идентификаторов, имеющихся в исходном коде языка Haskell, в полностью квалифицированные имена, одновременного определения того, выходят ли идентификаторы за область своей видимости, и, если это имеет место, создания соответствующих пометок об ошибках.

В языке Haskell можно в модуле повторно экспортировать идентификатор, который импортируется из другого модуля. Например, предположим, что в модуле A определяется функцию с именем f, а в модуле B импортируется модуль A и повторно экспортируется f. Теперь, если модуль C импортирует модуль B, он может ссылаться на f по имени B.f, хотя первоначально f было определено в модуле A. Это полезный способ работы с пространством имен; он означает, что библиотеку можно использовать в любом месте внутри структуры модулей, где вы пожелаете, и, при этом, предоставлять понятный и аккуратный интерфейс API через несколько интерфейсных модулей, в которые повторно экспортируют идентификаторы из внутренних модулей.

Однако компилятор для того, чтобы знать, чему соответствует каждое имя в исходном коде, должен во всем этом разобраться. Мы строго различаем сущности (entities), т. е. «сами предметы» (в нашем примере, A.f), и имена, с помощью которых можно обращаться к сущностям (например, B.f). В любом конкретном месте в исходном коде, в области видимости есть набор сущностей, и для каждой из них может быть известно одно или несколько различных имен. Задача блока переименования заключается в замене каждого из имен в внутреннем представлении кода, имеющегося в компиляторе, на ссылку на конкретную сущность. Иногда имя может ссылаться на несколько сущностей; само по себе это не является ошибкой, но если имя действительно используется, то блок переименования установит флаг ошибки неоднозначности и отвергнет программу.

При переименовании в качестве входа берется абстрактный синтаксис Haskell (HsSyn ,RdrName), а на выходе получают (HsSyn Name). Здесь Name является ссылкой на конкретную сущность.

Разрешение имен является основной работой блока переименования, но он также выполняет множество других задач: анализ функций и установка флага ошибки в случае, если у функций не совпадает количество аргументов; преобразование инфиксным выражений согласно приоритету выполнения операций; выявление объявлений-дубликатов; генерация предупреждений о неиспользуемых идентификаторах, и так далее.

Проверка типов

Проверкой типов, как можно было бы предположить, является процесс проверки того, что программа на языке Haskell является корректной с точки зрения используемых типов. Если программа проходит проверку типов, то ее работа гарантированно не закончится крахом во время выполнения. Термин «крах» здесь имеет формальное определение, которое включает в себя сбой работы аппаратных средств, например, «ошибка сегментации», но не включает в себя такие ситуации, как ошибки сопоставления с образцом. Гарантия того, что работа программы не закончится крахом, может быть нарушена использованием определенных небезопасных функций языка, например, использованием интерфейса доступа к внешним функциям Foreign Function Interface.

В качестве входных данных блока проверки типа берется HsSyn Name (исходный код на языке Haskell с полностью квалифицированными именами), а на выходе получают HsSyn Id. Идентификатор Id является именем Name с дополнительной информацией: в частности типом. На самом деле, синтаксические конструкции языка Haskell, создаваемые блоком проверки типов, сопровождаются полным набором информации, касающейся типов: для каждого идентификатора указывается его собственный тип данных, а также есть вся информация, достаточная для преобразования подвыражений любых типов (например, это может использоваться в оболочках IDE).

На практике, проверка типов и переименование могут чередоваться, т.к. из-за использованию шаблонов языка Haskell во время выполнения происходит генерация кода, для которого необходимы переименование и проверка типов.

Сокращение синтаксического разнообразия языка и язык Core

Язык Haskell является довольно большим языком, в котором есть много различных синтаксических форм. Его назначение быть легким для тех, кто на нем читает и пишет; в нем присутствует широкий спектр синтаксических конструкций, которые дают программисту большую гибкость в выборе наиболее подходящей конструкцией для конкретной ситуации. Однако эта гибкость означает, что часто для одного и того же кода есть несколько способов записи; например, выражение if по смыслу идентично выражению case с вариантами True и False, а сложная нотация списков может быть преобразована в обращения к функциям map, filter и concat. В действительности, в определении языка Haskell указывается, как все эти конструкции переводятся в более простые конструкций; конструкций, в которые можно выполнить преобразование без всякого, так называемого, «синтаксического сахара».

Для компилятора гораздо проще, если весь синтаксический сахар удален, поскольку при последующих проходах оптимизации, которые необходимо использовать с программой на языке Haskell, работа будет происходить с языком меньшего размера. Поэтому процесс сокращения синтаксического разнообразия языка удаляет весь синтаксический сахар, переводя полный синтаксис языка Haskell в гораздо меньший язык, который мы называем базовым языком. Мы позже поговорим подробно о базовом языке Core.

Оптимизация

Теперь, когда программа представлена в виде базового языка Core, начинается процесс оптимизации. Одна из самых сильных сторон компилятора GHC состоит в оптимизации, в ходе которой убираются слои абстракции и вся эта работа происходит на уровне базового языка Core. Язык Core является крошечным функциональным языком, но это чрезвычайно гибкое средство для выполнения оптимизаций, начиная с самого высокого уровня, например, проверки строгости конструкций, и до самого низкого уровня, например, сокращения числа действий.

Each of the optimisation passes takes Core and produces Core. The main pass here is called the Simplifier, whose job it is to perform a large collection of correctness-preserving transformations, with the goal of producing a more efficient program. Some of these transformations are simple and obvious, such as eliminating dead code or reducing a case expression when the value being scrutinised is known, and some are more involved, such as function inlining and applying rewrite rules (discussed later). При каждой оптимизации берется язык Core и создается язык Core. При этом главный проход называется упрощающим (Simplifier), работа которого заключается в применении большого набора преобразований, не нарушающих правильность программы, с целью получения более эффективной программы. Некоторые из этих преобразований просты и очевидны, например, устранение недоступного кода или сокращение выражения case после тщательного исследования значения, а некоторые — более сложные, например, подстановка функций непосредственно в код и применение правил грамматического вывода (будут рассмотрены ниже).

Упрощающий подход обычно выполняется между другими проходами оптимизация, которых насчитывается до шести; какие проходы на самом деле работают и в каком порядке, зависит от уровня оптимизации, который выбирает пользователь.

Генерация кода

После того, как будет оптимизирована программа Core, начинается процесс генерации кода. После нескольких административных проходов, код преобразуется программу одного из следующих двух видов: либо он превращается в байт-код для исполнения интерактивным интерпретатором, либо он передается в генератор кода для последующего перевода в машинный код.

Генератор кода сначала преобразует программу Core в язык, называемый STG, который, по существу, представляет собой аннотированный вариант Core с дополнительной информацией, которая необходима генератору кода. Затем STG транслируется в Cmm, низкоуровневый императивный язык с явным использованием стека. С этого момента преобразование кода может пойти по одному из следующих трех вариантов:

5.3. Ключевые проектные решения

В этом разделе мы сосредоточимся на нескольких проектных решений, которые оказались особенно эффективными в компиляторе GHC.

Промежуточный язык

>

Выражения

 

t,e,u

::=

x

Переменные

 

|

K

Конструкторы данных

 

|

k

Литералы

 

|

λ x:σ.e|e u

Абстракция значений и приложения

 

|

Λ a:η.e |e φ

Абстракция типов и приложения

 

|

let x:τ = e in u

Локальное связывание

 

|

case e of p→u

Выражения типа case

 

|

e ▷ γ

Приведения типов

 

|

⌊ γ ⌋

Ограничения

p

::=

K c:η x:τ

Шаблоны

Рис.5.3: Синтаксис языка Core

Типичная структура компилятора для статически-типизированного языка следующая: в программе проверяются типы данных и, прежде чем будет выполняться оптимизация, программа будет преобразована в некоторый промежуточный нетипизированный язык. Компилятор GHC отличается: в нем используется статически-типизированный промежуточный язык. Как оказалось, это проектное решение повлияло на проектирование и разработку компилятора GHC.

Промежуточный язык, используемый в компиляторе GHC, называется Core (когда рассматриваем реализацию) или системой FC (когда мы имеем дело с теорией). Его синтаксис приведен на рисунке 5.3. Точные детали здесь не важны, более подробную информацию заинтересованный читатель может найти в [SCPD07]. Однако, для наших целей ключевыми являются следующие моменты:

Весь анализ и оптимизация, которые выполняются компилятором GHC, переносятся в язык Core. Это очень удобно: поскольку Core является таким крошечным языком, есть всего несколько вариантов оптимизации. Хотя язык Core небольшой, он крайне выразителен, система F, в конце концов, изначально разрабатывалась как фундаментальное исчисление для типизированных расчетов. Когда к языку, на котором пишется исходный код, добавляются новые возможности (что иногда происходит), изменения, как правило, ограничены только внешним языком; неизменным остается язык Core, и, следовательно, большая часть компилятора.

Но почему язык Core типизирован? В конце концов, если движок вывода типов получает на вход исходную программу, то эта программа, по-видимому, хорошо типизирована и на каждой фазе оптимизации, как предполагается, сохраняется такая корректность типов, но почему вы всегда хотите запускать этот движок? Более того, для того, чтобы сделать язык Core типизированным, требуются значительные затраты; поскольку при каждом проходе преобразования или оптимизации требуется создавать правильно типизированную программу, а генерация аннотаций всех таких типов часто является нетривиальной задачей.

Тем не менее, наличие явно типизированного промежуточного языка стало огромной победой по следующим нескольким причинам:

На практике язык Core оказался невероятно стабильным: в течение 20-летнего периода мы добавили в Core ровно одну новую основную возможность (а именно, coercions и связанные с ней приведения типов). За тот же период, исходный язык был расширен весьма существенно. Мы объясняем эту стабильность не нашими собственными достижениями, а, скорее всего, тем, что Core базируется на фундаментальной математике: браво Жерар!

Проверка типов исходного языка

Одно интересное проектное решение связано с тем, должна ли проверка типов осуществляться до или после приведения исходного языка к языку Core. Компромиссы, таковы:

Большинство компиляторов выполняет проверку типов после приведения программы, но для компилятора GHC мы сделали иной выбор: мы делаем проверку типов для полного синтаксиса исходного языка Haskell, а затем для полученного результата осуществляем приведение. Кажется, что добавлять новые синтаксические конструкции станет сложно, но (следуя французской школе разработки компиляторов) мы структурировали движок вывода типов таким образом, что это делается достаточно просто. Вывод типов состоит из двух частей:

  1. Генерация ограничений: выполняется обход синтаксического дерева исходного кода и генерируется коллекция ограничений типов. На этом шаге рассматривается полный синтаксис языка Haskell, но это очень простой код и в нем легко добавлять новые случаи.
  2. Решения, относящиеся к ограничениям: принимаются решения относительно каждого созданного ограничения. Это именно то место, где используется движок вывода типов, но он не зависит от синтаксиса исходного языка и был бы точно такой же для существенно меньшего или гораздо большего языка.

В целом, выбор варианта, когда проверка типов осуществляется перед приведением программы, дает больший выигрыш. Да, при этом увеличивается количество строк кода в программе проверки типов, но это простые строки. Исчезает ситуация, когда одному и тому же типу данных присваивается две противоречивые роли, и движок вывода типов становится менее сложным и его легче изменять. Кроме того, сообщения компилятора GHC об ошибке типов становятся сравнительно простыми.

Без таблиц символов

В компиляторах обычно есть одна или большее количество структур данных, известных как таблицы символов, которые являются отображением символов (например, переменных) в некоторую информацию о переменной, например, информацию о ее типе, или о месте, где она была определена в исходном коде.

В компиляторе GHC мы используем таблицы символов довольно скупо — главным образом при переименовании и проверке типов. Там, где это возможно, мы используем альтернативные стратегии: переменная является структурой данных, в которой содержится вся информация о самой переменной. Действительно, когда мы проходим через структуру данных переменной, то можем получить большое количество информации: из переменной мы можем узнать ее тип, в котором есть конструкторы типа, которые содержат конструкторы данных, в которых самих есть типы данных, и так далее. Например, вот некоторые типы данных компилятора GHC (сильно сокращенный и упрощенный вариант):

data Id      = MkId Name Type
  data Type    = TyConApp TyCon [Type]
               | ....
  data TyCon   = AlgTyCon Name [DataCon]
               | ...
  data DataCon = MkDataCon Name Type ...

Идентификатор Id содержит свой тип Type. Тип Typ может быть применением конструктора типа к некоторым аргументам (например, Maybe Int), в этом случае он содержит тип TyCon. Tип TyCon может быть алгебраическим типом данных, в этом случае в нем указывается список его конструкторов данных. В каждом конструкторе DataCon есть свой собственный тип Type, о котором, конечно, упоминается в типе TyCon. И так далее. Все составляющие структуры тесно взаимосвязаны. В самом деле может быть цикл: тип данных TyCon может содержать конструктор DataCon, в котором есть тип Type, в которое содержится тот самый тип TyCon, с которого мы начали.

В таком подходе есть ряд преимуществ и недостатков:

Трудно определить, будут ли в общем использоваться таблицы символов лучше или хуже, поскольку этот аспект разработки настолько фундаментален, что его почти невозможно изменить. Тем не менее, отказ от использования таблиц символов является естественным выбором в чисто функциональной среде, поэтому вполне вероятно, что такой подход является хорошим вариантом для языка Haskell.

Межмодульная оптимизация

Функциональные языки поощряют программиста писать небольшие определения. Например, определение && из стандартной библиотеки выглядит следующим образом:

(&&) :: Bool -> Bool -> Bool
True && True = True
_    && _    = False

Если при каждом использовании такой функции действительно потребуется вызов функции, то эффективность будет ужасной. Одним из решений является заставить компилятор обрабатывать некоторые функции специальным образом, другое решение состоит в использовании препроцессора, который заменит вызов «call» прямой вставкой кода в строку (inline code). Все эти решения неудовлетворительны в той или иной форме, тем более, что настолько очевидно другое решение: просто вставить функцию. «Вставить функцию» означает замену вызова функции на копию тела функции с подстановкой соответствующих экземпляров ее параметров.

В компиляторе GHC мы систематически использовали этот подход [PM02]. В компилятор практически ничего не встроено. Вместо этого, мы для того, чтобы устранить накладные расходы, как можно больше определяем все в библиотеках и агрессивно используем вставки в код. Это означает, что программисты могут определять свои собственные библиотеки, которые будут вставляться в код и будут оптимизироваться, а также те библиотеки, которые приходят вместе с компилятором GHC.

Следствием является то, что в компиляторе GHC должна быть возможность делать в код межмодульные вставки и даже межпакетные вставки. Идея проста:

По умолчанию компилятор GHC будет показывать определение функции в интерфейсном файле только в том случае, если функция «маленькая» (есть флаги, контролирующие размер этого порога). Но мы также поддерживаем параметр INLINE Pragma, который указывает компилятору GHC в любом случае вставлять следующим образом определение везде, где используется вызов, причем независимо от его размера:

foo :: Int -> Int 
{-# INLINE foo #-}
foo x = <некоторое большое выражение>

Кросс-модульная вставка в код является абсолютно необходимым средством для получения супер-эффективных библиотек, но это имеет свою цену. Если автор обновляет свою библиотеку, то недостаточно перекомпоновать файл Client.o с новой библиотекой Lib.o, поскольку в Client.o есть фрагменты старой библиотеки Lib.hs, которые вставлены непосредственно в код и которые могут оказаться несовместимыми с новой библиотекой. Другой способ — это указать, что ABI (Application Binary Interface) библиотеки Lib.o был изменен так, что требуется перекомпиляция клиентских модулей.

На самом деле, единственным способом компиляции кода, сгенерированного с фиксированным предсказуемым интерфейсом ABI, является отключение межмодульной оптимизации, а это, как правило, слишком высокая цена, чтобы заплатить за совместимость с ABI. У пользователей, работающих с компилятором GHC обычно есть весь исходный код, поэтому перекомпиляция, как правило, не является проблемой (и, как мы далее узнаем, пакет system создан именно для такого режима работы). Тем не менее, есть ситуации, когда с практической точки зрения перекомпиляция не подходит: например, распространение исправлений ошибок в библиотеках, которые поставляются в двоичном дистрибутиве ОС. В будущем, как мы надеемся, возможно удастся найти компромиссное решение, которое позволит сохранить совместимость с ABI и, то же время, позволит использовать некоторые варианты межмодульной оптимизации.

5.4. Средства расширяемости

Часто бывает, что проект живет или умирает в зависимости от того, насколько он расширяем. Монолитный кусок программного обеспечения, которой не является расширяемым, должен делать все и должен делать это правильно, в то время как расширяемый фрагмент программного обеспечения, может быть полезным даже в случае, если в нем не еще реализованы сразу «из коробки» все необходимые функции.

Проекты с открытым исходным кодом, конечно, расширяемые по определению, причем каждый разработчик может взять код и добавить в него свои собственные функции. Но модификация оригинального исходного кода проекта, осуществляемая кем-то еще, не только требует больших накладных расходов, но также не способствует распространению ваших расширений среди других пользователей. Поэтому в успешных проектах, как правило, предоставляются свои собственные варианты расширений, которые не связаны с изменением основного кода, и в этом отношении компилятор GHC не является исключением.

Правила преобразований, определяемые пользователями

Ядро компилятора GHC представляет собой длинную последовательность проходов оптимизации, каждый из которых выполняет некоторое преобразование из Core в Core, сохраняющее семантику. Но автор библиотеки определяет функции, для которых часто требуются некоторые свои собственные нетривиальные предметно-ориентированные преобразования, которые никак не могут быть предсказаны компилятором GHC. Итак компилятор GHC позволяет авторам библиотек определять правила переписывания (rewrite rules), которые применяются для того, чтобы преобразовывать программы в процессе оптимизации [PTH01]. Таким образом, программисты могут, по сути, расширять компилятор GHC с помощью предметно-ориентированных вариантов оптимизации.

Одним из примеров является правило foldr/build, которое выражается следующим образом:

{-# RULES "fold/build"    
    forall k z (g::forall b. (a->b->b) -> b -> b) . 
       foldr k z (build g) = g k z
 #-}

Все правило является параметром pragma, которое вводится с помощью {-# RULES. В правиле говорится, что всякий раз, когда компилятор GHC видит выражение (foldr k z (build g)), он должен переписать его как (g k z). Это преобразование сохранит семантику, но что это именно так, нужно изучить статью [GLP93], так что нет никаких шансов, что компилятор GHC выполнит это правило автоматически. Если воспользоваться еще несколькими другими правилами и некоторыми параметрами INLINE pragma, то компилятор GHC сможет сливать вместе функции преобразования списков. Например, два цикла (map f (map g xs)) объединяются в один.

Хотя правила переписывания просты и ими легко пользоваться, они оказались очень мощным механизмом расширения. Когда мы десять лет назад впервые добавили эту возможность в компилятор GHC, мы предполагали, что это будет полезная возможность, которой будут пользоваться лишь изредка. Но на практике она оказалась полезной в очень многих библиотеках, эффективность которых часто во многом зависит от правил переписывания. Например, в собственной библиотеки компилятора GHC base содержится свыше 100 правил, а в популярной библиотеке vector используется несколько десятков правил.

Плагины компилятора

Один из способов, с помощью которого можно расширять компилятор, это разрешить программистам писать проход компиляции, который будет вставлен непосредственно в конвейер, используемый в компиляторе. Такие проходы часто называются «плагинами». В GHC плагины поддерживаются следующим образом:

Но что такое «соответствующее место в конвейере»? Компилятор GHC этого не знает, и поэтому позволяет плагину принять соответствующее решение. Из-за этого и ряда других причн, интерфейс API, который должен быть реализован в плагине, несколько сложнее, чем просто функция, действующая из Core в Core, но не более.

Для плагинов иногда требуются вспомогательные данные, либо плагины сами могут создавать такие данные. Например, плагин может выполнить некоторый анализ функций в компилируемом модуле (скажем, в модуле M.hs), и, возможно, есть смысл поместить эту информацию в интерфейсный файл M.hi, с тем чтобы у плагина был доступ к этой информации в случае, когда компилируются модули, в которые импортируется модуль M. Для поддержки такой возможности в компиляторе GHC предоставляется механизм аннотаций.

Плагины и аннотации являются относительно новыми возможностями в компиляторе GHC. Они имеют более высокий порог вхождения, чем правила переписывания, поскольку плагин работает с внутренними структурами данных компилятора GHC, но с их помощью, конечно, можно делать гораздо больше. Еще предстоит выяснить, насколько широко будут они использоваться.

Компилятор GHC как библиотека: интерфейс API компилятора GHC

Одной из первоначальных целей разработки компилятора GHC было создание модульной базы, на основе которой можно было бы строить все остальное. Мы хотели, чтобы код GHC был максимально прозрачным и настолько хорошо документированным, насколько это было возможным, для того, чтобы его могли использовать другие разработчики в качестве основы для исследовательских проектов; нам казалось, что другие разработчики захотели бы внести в компиляторе GHC свои собственные изменения с тем, чтобы добавить новые экспериментальные возможности или варианты оптимизации. Действительно, было несколько примеров таких изменений: например, есть версия GHC с языком Lisp в качестве входного интерфейса, а также версия GHC, которая генерирует код на языке Java, причем обе разработки делались совершенно независимо лицами, которые очень слабо контактировали или совсем не контактировали с командой разработчиков компилятора GHC.

Однако создание модифицированных версий компилятора GHC представляет собой лишь небольшую часть из тех приложений, в которых можно повторно использовать код компилятора GHC. По мере того, как растет популярность языка Haskell, наблюдается увеличение потребности в инструментальных средствах и инфраструктуре, которые должны уметь анализировать исходный код на языке Haskell, и в компиляторе GHC, конечно, есть много функций, необходимых для создания подобных инструментальных средств: парсер (синтаксический анализатор) языка Haskell, абстрактный синтаксис, средства проверки типов и так далее.

Имея это в виду, мы внесли в GHC простое изменение: вместо сборки GHC в виде монолитной программы, мы собираем GHC в виде библиотеки, которая компонуется затем с небольшим модулем Main, позволяющим сделать компилятор GHC исполняемым файлом, но которая также поставляется и как библиотека, так что пользователи могут вызывать компилятор из своих собственных программ. Одновременно мы собираем интерфейс API, с помощью которого функциональные возможности компилятора GHC становятся доступными для клиентских модулей. Интерфейс API предоставляет достаточно много функций с тем, чтобы можно было реализовать пакетный компилятор GHC и интерактивную среду GHCi, он позволяет получить доступ к отдельным проходам компиляции, например, синтаксическому анализу и к проверке типов, а также дает возможность выполнять проверку структур данных, создаваемых с помощью этих проходов. Такое изменение позволило разработать широкий спектр инструментальных средств, созданных с применение интерфейса API компилятора GHC, в том числе:

Система пакетов

Система пакетов была в последние годы ключевым фактором роста использования языка Haskell. Ее основное назначение заключается в предоставлении программистам, использующим язык Haskell, возможности пользоваться кодом, созданным другими, поскольку это важный аспект расширяемости: система пакетов применяется в совместно используемом коде самого компилятора GHC и за его пределами.

В системе пакетов реализованы различные фрагменты общей инфраструктуры, которые вместе позволяют достаточно просто пользоваться общим кодом. Благодаря наличию системы пакетов, само сообщество создало очень много совместно используемого кода; вместо того, чтобы полагаться на библиотеки, получаемые из одного источника, программисты, использующие язык Haskell, пользуются библиотеками, созданными всем сообществом. Такая модель хорошо зарекомендовала себя для других языков; например, система CPAN для Perl, хотя то, что язык Haskell является преимущественно компилируемым, а не интерпретируемым языком, ведет к несколько другому набору проблем.

В своей основе система пакетов позволяет пользователю управлять библиотеками кода Haskell, написанными другими людьми, и использовать их в своих собственных программах и библиотеках. Установка библиотеки Haskell выполняется просто с помощью всего одной команды, например, команда:

$ cabal install zlib

загружает код пакета zlib с сайта http://hackage.haskell.org, компилирует его с помощью компилятора GHC, устанавливает скомпилированный код где-нибудь в вашей системе (например, в вашем домашнем каталоге в системе Unix), и с помощью компилятора GHC регистрирует установленный пакет. Кроме того, если пакет zlib зависит от других пакетов, которые еще не установлены, то перед компиляцией самого пакета zlib они также будут загружены, скомпилированы и установлены. Это чрезвычайно удобный способ работы с совместно используемыми библиотеками кода на языке Haskell.

Пакет система состоит из четырех компонентов, причем только первый из них собственно является частью проекта GHC:

Эти компоненты разрабатывались в течение нескольких лет членами сообщества Haskell и командой разработчиков компилятора GHC, и теперь эти компоненты вместе образуют систему, которая прекрасно вписывается в модель разработки с использованием открытого исходного кода. Нет никаких барьеров, которые бы мешали совместно использовать код, а также тот код, которые другие разработчики объявляют как совместно используемый (конечно при условии, что вы соблюдаете соответствующие лицензии). Вы можете использовать пакет, который кто-то написал, буквально через несколько секунд после того, как найдете его на сайте Hackage.

Сайт Hackage оказался настолько успешным, что среди оставшихся проблем у него теперь те, что связанны с масштабированием: например, пользователи считают, что трудно делать выбор одного из четырех различных фреймворков баз данных. Текущие разработки направлены на решение этих проблем так, чтобы это устроило сообщество. Например, возможность, разрешающая пользователям комментировать пакеты и назначать им баллы, позволит облегчить поиск лучших и самых популярных пакетов, а возможность, позволяющая собирать от пользователей данные об успешной или неудачной сборке пакетов и получать отчеты, поможет им избегать использовать те пакеты, у которых нет сопровождения или в которых есть проблемы.

5.5. Система времени выполнения

Система времени выполнения (Runtime System - RTS) представляет собой библиотеку, написанную, в основном в коде на C, которая компонуется с каждой программой Haskell. С ее помощью поддерживается инфраструктура, необходимая для запуска скомпилированного кода Haskell. Поддерживаются следующие основные компоненты:

Оставшаяся часть раздела разделена на две части: во-первых, мы сосредоточимся на нескольких проектных решениях системы RTS, которые, как мы считаем, оказались успешными и благодаря им работа этой системы оказалась настолько хорошей, а, во-вторых, мы поговорим о практике кодирования и инфраструктуре, которую мы создали в системе RTS для того, чтобы справляться с довольно недружелюбной средой программирования.

Ключевые проектные решения

В этом разделе мы рассмотрим два проектных решения системы RTS, которые мы рассматриваем, как особенно успешные.

Слой блоков памяти

Сборщик мусора построен поверх слоя, управляющего блоками памяти, размер которых кратен 4 Кб. Слой блоков памяти имеет очень простой интерфейс API:

typedef struct bdescr_ {
    void *               start;
    struct bdescr_ *     link;
    struct generation_ * gen;   // генерация
    // .. другие различные поля
} bdescr;

bdescr * allocGroup (int n);
void     freeGroup  (bdescr *p);
bdescr * Bdescr     (void *p);  // макро

Это единственный интерфейс API, используемый сборщиком мусора для выделения и освобождения памяти. Блоки памяти выделяются с помощью операции allocGroup и освобождаются с помощью операции freeGroup. В каждом блоке есть небольшая структура, ассоциированная с эти блоком, которая называется дескриптором блока - block descriptor (bdescr). Операция Bdescr(p) возвращает дескриптор блока, ассоциированный с произвольным адресом p; это только расчет адреса, который вычисляется исходя из значения p и компилируется в несколько арифметических инструкций и манипуляций с битами.

Сборщик мусора должен управлять несколькими различными областями памяти, например, выделяемые по разные нужды, и каждая из этих областей, возможно, должна с течением времени увеличиваться или уменьшаться. Когда области памяти представлены в виде списка связанных блоков, то в сборщике мусора GC не возникает трудностей с выделением в непрерывном адресном пространстве областей памяти различного размера.

В реализации слоя блоков памяти использовались методы, хорошо известные в интерфейсе API языка C — malloc()/free(); поддерживаются списки свободных блоков различных размеров и выполняется объединение соседних свободных областей. Тщательно разрабатывались операции freeGroup() и allocGroup() с тем, чтобы их сложность была O(1).

Одно из главных преимуществ такого проектного решения состоит в том, что для него требуется очень незначительная поддержка со стороны операционной системы, и, следовательно, оно хорошо подходит для обеспечения переносимости. В слое блоков необходимо выделять память размером, кратным 1 Мб, с выравниванием по границе 1 Мб. Хотя ни в каких из обычных ОС такая функциональная возможность не предоставляется непосредственно, она, с учетом того, что эти ОС действительно предоставляют, реализуется без особых трудностей. Преимущество в том, что компилятор GHC не зависит от конкретных особенностей организации адресного пространства, используемого ОС, и он мирно сосуществует с другими потребителями адресного пространства, такими как совместно используемые библиотеки и потоки операционной системы.

Имеются дополнительные накладные расходы, затрачиваемые слоем блоков памяти на управление цепочками блоков вместо того, чтобы пользоваться непрерывно выделенной памятью. Впрочем, мы обнаружили, что эти расходы больше, чем затраты на обеспечение гибкости и переносимости; например, в слоек блоков есть вполне определенный простой алгоритм параллельной сборки мусора, который необходимо реализовать [MHJP08].

Легковесные потоки и распараллеливание

Мы считаем, что параллельная обработка должна быть жизненно важной абстракцией программирования, в частности, для создания таких приложений, как веб-серверы, которые должны одновременно взаимодействовать с большим количеством внешних агентов. Если параллельная обработка является важной абстракцией, то она не должна быть настолько дорогой, что программисты вынуждены ее избегать или создавать сложную инфраструктуру, помогающую справиться с этой сложностью (например, пулы потоков). Мы считаем, что параллельная обработка должна просто работать и должна быть достаточно дешевой с тем, что вы в небольших задачах не беспокоились об образовании новых потоков.

Во всех операционных системах предоставляются потоки, которые работают отлично, проблема в том, что они слишком дороги. Типичная ОС справляется с обработкой тысячи потоков, тогда как мы хотим управлять миллионами потоков.

«Зеленые» потоки (green threads), иначе известные как легковесные потоки или потоки пользовательского пространства, представляют собой хорошо известную технологию, которая позволяет избежать перегрузки потоками операционной системы. Идея состоит в том, что управление потоками осуществляется с помощью самой программы или с помощью библиотеки (в нашем случае, системы RTS), а не с помощью операционной системы. Управление потоками в пространстве пользователя должно быть менее затратным, поскольку в операционной системе будет меньшее количество взаимодействий между потоками.

В системе RTS компилятора GHC мы в полной мере воспользовались этой идеей. Переключение контекста происходит только тогда, когда поток находится в безопасной точке (safe point), в которой требуется сохранять очень небольшое количество дополнительных состояний. Потому что мы используем аккуратный сборщик мусора с тем, чтобы по требованию можно было перемещать стек потоков и увеличивать или уменьшать его размеры. Эти потоки резко отличаются от потоков операционной системы, в которых при каждом переключении контекста необходимо сохранять все состояние процессора и в которых стеки представляют собой неперемещаемый большой кусок адресного пространства, которое для каждого потока должно быть предварительно зарезервировано.

Зеленые потоки могут быть гораздо более эффективными, чем потоки ОС, так зачем кому-то использовать потоки ОС? С зелеными потоками возникают следующие три основные проблемы:

Оказывается, что все это трудно реализовывать с зелеными потоками. Тем не менее, мы упорно продолжали в компиляторе GHC заниматься зелеными потоками и нашли решения для всех трех типов проблем:

Таким образом, в подавляющем большинстве случаев, потоки языка Haskell ведут себя так же, как потоки операционной системы: в них могут возникать блокировки, связанные с вызовами операционной системы, которые не будут влиять на другие потоки, и они работают параллельно на многоядерной машине. Но они на порядки более эффективны с точки зрения затрат времени и памяти.

При этом в реализации есть одна проблема, с которой пользователи иногда сталкиваются, в частности, в случае, когда измеряют производительность программы. Выше мы говорили, что легковесные потоки выигрывают по эффективности только при переключении контекста в «безопасных точках», местах в коде, которые компилятор определяет как безопасные, когда внутреннее состояние виртуальной машины (стек, куча, регистры и т.д. ) находятся в надлежащем порядке и когда может использоваться сборка мусора. В компиляторе GHC безопасной точкой является место, где происходит выделение памяти, что почти во всех программах Haskell происходит достаточно регулярно, так что программы выполняют не более, чем несколько десятков инструкций, не попав в безопасную точку. Тем не менее, в хорошо оптимизированном коде можно найти циклы, которые выполняют много итераций без выделения памяти. Это, как правило, часто происходит в программах оценки производительности (например, в функциях, вычисляющих факториал или числа Фибоначчи). В реальном коде такая ситуация встречается реже, хотя это также случается. Отсутствие безопасных точек не позволяет запустить планировщик, что может иметь пагубные последствия. Эту проблему можно решить, но не без ухудшения производительности таких циклов, и часто это сводится к сохранению результата каждой итерации во внутренних циклах. Возможно, это просто компромисс, с которым мы должны смириться.

5.6. Разработка компилятора GHC

Компилятор GHC является отдельным проектом с двадцатилетним сроком существования, при этом он все еще находится в состоянии постоянных инноваций и разработки. По большей части наша инфраструктура разработки и инструментальные средства являются обычными. Например, мы используем треккер ошибок (Trac), вики (также Trac) и Git в качестве средства контроля версий. Такой контроль версий сначала осуществлялся чисто вручную, затем - CVS, затем - Darcs, пока, наконец, в 2010 году мы не перешли на Git. Есть несколько моментов, которые, возможно, менее обычны, и мы их здесь рассмотрим.

Комментарии и замечания

Одна из наиболее серьезных трудностей в большом долгоживущем проекте — это поддержка технической документации в актуальном состоянии. У нас нет серебряной пули, но мы предлагаем один малотехнологичный прием, который послужил нам особенно хорошо: замечания Notes.

При написании кода, часто возникает момент, когда осторожный программист захочет мысленно сказать что-то вроде следующего: «У этого типа данных есть важный инвариант». Он сталкивается с двумя вариантами, которые оба ему не нравятся. Он может добавить инвариант как комментарий, но из-за этого определение типа может стать слишком длинным так, что трудно будет понять, что является конструкторами. Кроме того, он может документировать инвариант в другом месте, и есть риск, что данные устареют. За двадцать лет устареет все!

Исходя из такой мотивации мы разработали следующее очень простое соглашение:

Мало того, что можно перейти от кода, в котором указывается ссылка на замечание, к самому замечанию, возможно также и обратное, что тоже часто бывает полезным. Кроме того, на одно и то же замечание можно ссылаться из нескольких мест в коде.

Это простая методика, использующая только код ASCII без автоматизированной поддержки, преобразовала нашу жизнь: в компиляторе GHC есть около 800 замечаний и их количество растет с каждым днем.

Как поддерживать рефакторинг

Код компилятора GHC создается так же быстро, как это было десять лет назад, если не быстрее. Нет сомнения в том, что за этот период времени сложность системы возросла многократно; мы ранее рассказывали об общем количестве кода в компиляторе GHC. Тем не менее, система остается управляемой. Мы связываем это с тремя основными факторами:

Отступления от правил

Когда мы оглядываемся назад на те изменения, которые нам потребовалось сделать для того, чтобы проект GHC вырос таким образом, нам становится очевиден общий урок: когда мы отходили от чистой функциональности, будь то в целях эффективности или удобства, то, как правило, это впоследствии приводило к негативные результатам. Этому у нас есть несколько крупных примеров:

Разработка системы RTS

Система времени выполнения компилятора GHC представляет собой во многих отношениях разительный контраст с самим компилятором. Есть естественные различия, поскольку система времени выполнения написана на языке С, а не на языке Haskell, но есть также и соображения, уникальные для системы RTS, которые ведут к другой философии проектирования:

  1. Каждая программа на языке Haskell тратит много времени на выполнение кода в системе RTS: обычно это около 20 - 30%, но характеристики программ на языке Haskell очень сильно варьируются, поэтому также обычными бывают и значения, выходящие из этого диапазона как в большую, так и в меньшую сторону. Если система RTS оптимизирована, то выгоды возрастают многократно, так имеет смысл потратить много времени и усилий на оптимизацию.
  2. Система времени выполнения статически компонуется с каждой программой на языке Haskell (в случаях, когда не используется динамическая компоновка), так что есть стимул сделать эту систему поменьше.
  3. Ошибки времени выполнения часто непонятны пользователю (например, «ошибка сегментации») и их трудно обойти. Например, ошибки в сборщике мусора, как правило, не привязаны к использованию конкретной особенности языка, но они возникают, когда во время выполнения возникают некоторые сложные комбинации факторов. Кроме того, ошибки такого рода, как правило, являются непостоянными (случаются только при некоторых запусках программы) и они очень чувствительны к изменениям (крошечные изменения в программе приводят к исчезновению ошибки). С ошибками в многопоточной версии среды выполнения возникает еще больше проблем. Поэтому есть смысл прилагать дополнительные усилия для того, чтобы не допускать таких ошибок, а также для того, чтобы создать инфраструктуру, в которой такие ошибки будут легче идентифицироваться.

    Симптомы ошибок системы RTS часто неотличимы от двух других видов отказов: сбоя оборудования, которые происходят гораздо чаще, чем вы думаете, и неправильного использования небезопасных возможностей языка Haskell, например, интерфейса FFI (Foreign Function Interface — интерфейса внешних функций). Первое, что нужно сделать при диагностике отказов времени выполнения, это исключить эти две указанные причины.

  4. Система RTS является низкоуровневым кодом, который работает на машинах с несколькими различными вариантами архитектуры и с различными операционными системами, и она регулярно портируется на новые машины. Возможность портирования имеет важное значение.

Важным является каждый цикл и каждый байт, и, тем более, их правильность. Кроме того, задачи, выполняемые системой времени выполнения, сложны по своей природе, поэтому с самого начала трудно добиться их правильного выполнения. Согласование всех этих особенностей привело нас к некоторым интересным оборонительным приемам, о которых мы расскажем в следующих разделах.

Борьба со сложностью

Система RTS является сложной средой, которая враждебна программированию. В отличие от компилятора, система RTS практически не является типобезопасной. На самом деле, она еще менее типобезопасная, чем большинство других программ на языке C, т.к. она управляет структурами данных, типизация которых поддерживается на уровне языка Haskell, а не на уровне языка C. Например, в системе RTS не известно, что объект, на который ссылается последняя ячейка, является массивом или другим объектом: эта информация просто не существует на уровне языка C. Более того, процесс компиляции кода Haskell стирает информацию о типах, поэтому даже если мы бы сообщили системе RTS о том, что объект, на который происходит ссылка, является списком, все еще бы не было информации о голове списка. Поэтому в коде системы RTS требуется выполнять большое количество преобразований типов указателей языка C, причем с точки зрения безопасности типов это будет для компилятора C очень слабой подмогой.

Поэтому наше первое оружие в этой битве — избегать добавлений кода в систему RTS. Всегда, когда это можно, мы помещаем в систему RTS минимальное количество функций, а остальное пишем в библиотеке на языке Haskell. Это редко когда ухудшает результат; код на языке Haskell гораздо более надежен и белее компактен, чем на языке C, а его производительность, как правило, вполне приемлема. Точно нельзя сказать, где нужно проводить границу, хотя во многих случаях это достаточно ясно. Например, несмотря на то, что на языке Haskell теоретически можно реализовать сборку мусора, на практике это сделать чрезвычайно трудно, поскольку язык Haskell не позволяет программисту точно контролировать распределение памяти и т.п., поэтому на практике имеет смысл для такой низкоуровневой задачи перейти на уровень языка C.

Есть много функциональных возможностей, которые не удается (легко) реализовать на языке Haskell, а писать их в системе RTS не хочется. В следующем разделе мы сосредоточимся на одном аспекте управления сложностью и корректностью в системе RTS: на использовании инвариантов.

Инварианты и их проверка

В системе RTS есть много инвариантов. Многие из них являются тривиальными и легко проверяются: например, если указатель на голову очереди равен NULL, то указатель на хвост очереди также должен быть равным NULL. В коде системы RTS есть масса мест, где можно задать утверждения (assertion) для проверки аналогичных условий. Проверка утверждения является способом обнаружения ошибок раньше, чем они могут проявиться; на самом деле, когда добавляется новый инвариант, мы прежде чем писать код, реализующий инвариант, часто сначала добавляем утверждение.

Некоторые из инвариантов выявлять и проверять в процессе выполнения программ достаточно трудно. Один инвариантов этого вида, который больше относится к системе RTS, состоит в следующем: в куче не должно быть «висячих» указателей.

Висячие указатели добавить достаточно просто, и, как в компиляторе, так и в системе RTS, есть много мест, где может быть нарушен этот инвариант. Генератор кода может сгенерировать код, в котором создаются недопустимые объекты кучи; сборщик мусора, когда он сканирует кучу, может забыть обновить указатели некоторого объекта. На отслеживание ошибок этого вида может потребоваться очень много времени (однако, это является одним из любимых занятий автора статьи!), поскольку к тому времени, когда с программой, в конечном итоге, произойдет авария, процесс выполнения программы может пройти достаточно длинный путь от того места, где первоначально был добавлен висячий указатель. Есть хорошие средства отладки, но они не так хороши при прокрутке программы в направлении, обратном ходу ее исполнения. Однако, в последних версиях отладчика GDB и отладчика Microsoft Visual Studio действительно есть некоторая поддержка прокрутки программы в обратном направлении.

Общий принцип следующий: «если в программе дело идет к аварии, то это должно происходить, насколько это возможно, как можно быстрее, с большим количеством сообщений, которые должны выдаваться как можно чаще». Эта цитата взята из правил кодирования компилятора GHC, первоначально написанными Аластером Рейдом (Alastair Reid), который работал над ранней версией системы RTS.

Проблема в том, что не всегда инвариант отсутствия висячих ссылок можно проверить с помощью утверждения, требующего константного времени. Утверждение, с помощью которого делается эта проверка, должно сделать полный обход кучи! Очевидно, мы не сможем использовать это утверждение каждый раз, когда из кучи выделяется память, или каждый раз, когда сборщик мусора сканирует объект. На самом деле, даже этого было бы недостаточно, поскольку висячие указатели не появятся до тех пор, пока память не будет освобождена после окончания работы сборщика мусора.

Поэтому в отладочном варианте системы RTS есть дополнительный режим, который мы называем проверкой работоспособности (sanity checking). В процессе проверка работоспособности разрешается использовать все виды ресурсоемких утверждений и мы можем выполнять программу во много раз медленнее. В частности, в процессе проверки работоспособности проходит полное сканирование кучи с целью (среди всего прочего) проверить наличие висячих указателей до и после каждого обращения к сборщику мусора. Первое, что нужно сделать для того, чтобы выяснить причину аварии времени выполнения, это запустить программу с включенным режимом проверки работоспособности; иногда это позволит обнаружить нарушение инварианта задолго до того момента, когда программа фактически выходит из строя.

5.7. Заключение

За последние 20 лет авторы настоящей статьи вложили значительную часть своей жизни в проект GHC и мы весьма гордимся тем, как далеко он продвинулся. Это не единственная реализация языка Haskell, но это единственная реализация, которой регулярно пользуются сотни тысяч людей для того, чтобы выполнять реальную работу. Мы постоянно удивляемся, когда язык Haskell начинают использовать в необычных местах; одним из последних примеров является использование языка Haskell для управления системами в грузовиках-мусоровозах.

Для многих язык Haskell и компилятор GHC являются синонимами: эта цель никогда не планировалась, да и во многих случаях было бы контрпродуктивно иметь только одну реализацию стандарта, но дело в том, что поддержка хорошей реализации языка программирования требует большого объема работы. Мы надеемся, что наши усилия, затраченные в проекте GHC на поддержку стандарта и четкого выделения каждого отдельного расширения языка, сделают возможным появление других реализаций и их интеграцию с системой пакетов и другой инфраструктурой. Конкуренция выгодна для всех!

Мы выражаем глубокую благодарность фирме Microsoft, в частности, за предоставленную нам возможность разрабатывать компилятор GHC как часть наших исследований и распространять его с открытым исходным кодом.

6.1. Пара слов о Git

Git позволяет хранить копию данных работы (представленную обычно, но не всегда в форме программного кода), выполняемой взаимодействующими друг с другом участниками проекта с использованием распределенной сети репозиториев. Эта система контроля версий поддерживает распределение процессов разработки отдельных частей проекта, позволяя временно вносить несоответствия и в конечном счете объединять данные проекта.

В данной главе мы покажем то, как различные части проекта Git выполняют свои функции для реализации описанных возможностей, а также обозначим отличия этого проекта от других проектов систем контроля версий (VCS).

6.2. Начало развития проекта Git

Для лучшего понимания философии, стоящей за архитектурой проекта Git полезно понять обстоятельства, в которых участники сообщества разработчиков ядра Linux начали работу над проектом Git.

Проект разработки ядра Linux отличался от проектов разработки большинства коммерческих приложений того времени с точки зрения разработчиков, так как в его рамках работало большое количество людей, вносящих изменения в исходный код, причем эти люди значительно отличались по степени вовлеченности в проект и знаниям в области существующей кодовой базы. Разработка ядра в течение долгих лет велась с использованием архивов с исходным кодом и патчей, причем основные члены сообщества разработчиков пытались найти систему контроля версий, удовлетворяющую большей части их требований.

Git является проектом с открытым исходным кодом, который был создан для удовлетворения этих требований и исправления существующих недостатков процесса разработки в 2005 году. В это время кодовая база ядра Linux была распределена между двумя системами контроля версий BitKeeper и CVS в соответствии с предпочтениями различных разработчиков. Система BitKeeper предоставляла отличный взгляд на формирование истории системы контроля версий в отличие от популярных систем контроля версий с открытым исходным кодом того времени.

Через некоторое время создавшая систему BitKeeper компания BitMover объявила о намерении отозвать лицензии у некоторых разработчиков ядра Linux. После этого Linus Torvalds второпях начал разработку системы контроля версий, которая в данный момент носит имя Git. Он начал с разработки набора сценариев для упрощения процесса последовательного применения патчей из электронных писем. Целью разработки этого начального набора сценариев была реализация возможности быстрой отмены изменений исходного кода, причем у разработчика должна была быть возможность модификации кодовой базы для последующего наложения патча в ручном режиме с последующей возможностью возобновления автоматического применения патчей.

С самого начала разработки Linus Torvalds обозначил философию проекта Git - она была противоположна философии проекта CVS, к тому же он сформулировал три упрощающих использование системы цели проектирования:

Эти цели проектирования были приняты и поддержаны в некоторой степени, о чем я попытаюсь рассказать в ходе исследования методов использования в рамках Git ациклических ориентированных графов (DAG) для хранения данных, указателей ссылок для рабочих веток, представления объектной модели и удаленного протокола; и наконец о том, как Git отслеживает объединение древовидных представлений данных.

Несмотря на влияние, оказанное на начальную архитектуру Git проектом BitKeeper, реализация архитектурных решений имела фундаментальные отличия и позволила достичь большей степени распределения и возможности работы исключительно в локальном режиме, что было невозможно при использовании BitKeeper. Распределенная система контроля версий Monotone, разработка которой началась в 2003 году, похоже, была еще одним источником вдохновения, используемым на начальном этапе разработки Git.

Распределенные системы контроля версий позволяют достичь значительной гибкости рабочего процесса обычно ценой простоты. Специфические достоинства распределенной модели:

Во время начала работы над проектом Git была начата разработка трех других проектов распределенных систем контроля версий с открытым исходным кодом. (Одна из этих систем под названием Mercurial описывается в Главе 1 книги "Архитектура приложений с открытым исходным кодом".) Все эти распределенные системы контроля версий предоставляют незначительно отличающиеся способы формирования гибких рабочих процессов, которые не могли быть непосредственно реализованы при использовании централизованных систем контроля версий, существовавших до них. Примечание: система Subversion предусматривает возможность использования поддерживаемого различными разработчиками расширения SVK, предназначенного для синхронизации данных между серверами.

На сегодняшний день популярными и активно разрабатываемыми распределенными системами контроля версий с открытым исходным кодом являются Bazaar, Darcs, Fossil, Git, Mercurial и Veracity.

6.3. Архитектура системы контроля версий

Сейчас наступил отличный момент для того, чтобы сделать шаг назад и рассмотреть альтернативные Git реализации систем контроля версий. Понимание их отличий позволит исследовать принятые в ходе разработки Git архитектурные решения.

К системе контроля версий обычно предъявляются три ключевых функциональных требования, а именно:

Примечание: Третье требование из списка выше не является функциональным требованием для всех систем контроля версий.

Хранение данных

Наиболее часто используемыми архитектурными решениями для хранения данных в мире систем контроля версий являются наборы изменений на основе отличий файлов или представление данных в форме ациклического ориентированного графа (DAG).

Наборы изменений на основе отличий файлов инкапсулируют отличия между двумя версиями данных произвольного типа, дополняя их некоторым количеством метаданных. Представление данных в форме ациклического ориентированного графа подразумевает использование объектов, формирующих иерархию, которая зеркалирует содержимое дерева файловой системы в виде копии добавляемых данных (при этом не изменяющиеся объекты в рамках дерева используются повторно тогда, когда это возможно). Git хранит данные в виде ациклического ориентированного графа, использующего различные типы объектов. В разделе "База данных объектов", расположенном ниже этой главе, описываются различные типы объектов, которые могут формировать ациклические ориентированные графы в репозитории Git.

История операций внесения изменений и слияния

В области отслеживания изменений репозитория в большинстве систем контроля версий используется один из следующих подходов:

Для хранения данных истории в Git как и раньше используются направленные ациклические графы. Каждая операция изменения данных (commit) создает метаданные, указывающие на ее предшествующие этапы; операция изменения данных в Git может не иметь или иметь множество (теоретически неограниченное количество) родительских операций изменения данных. Например, первая операция изменения данных в репозитории Git не будет иметь родительских операций, а результат трехстороннего слияния ветвей будет иметь три родительских операции.

Другим ключевым отличием между системой Git и системой Subversion с ее линейным представлением истории предыдущих операций является возможность непосредственного выделения ветвей, в рамках которых будет производиться запись истории большинства операций объединения.

Пример представления данных истории в форме направленного ациклического графа в Git
Рисунок 6.1: Пример представления данных истории в форме направленного ациклического графа в Git

Git в полной мере поддерживает возможности выделения ветвей, используя при этом направленные ациклические графы для хранения данных. История изменений файла непосредственно связывается со структурой директорий (с помощью вершин, представляющих директории) до корневой директории, которая впоследствии связывается с ветвью операции изменения данных. Эта ветвь операции изменения данных, в свою очередь, может иметь одного или нескольких родителей. Это обстоятельство наделяет Git двумя возможностями, которые позволяют нам более четко ориентироваться в истории и данных, чем это возможно при использовании семейства систем контроля версий, созданных на основе RCS, а именно:

Распространение данных

Системы контроля версий осуществляют распространение данных рабочей копии между участниками процесса разработки проекта одним их трех способов:

Для демонстрации преимуществ и недостатков каждого из основных архитектурных решений, мы рассмотрим репозиторий системы Subversion и репозиторий системы Git (на сервере), содержащие идентичные данные (т.е., HEAD-ветвь, указывающая на рабочую ветвь репозитория Git будет содержать те же данные, что и последняя ревизия из поддиректории trunk репозитория Subversion). Разработчик по имени Alex создал локальную копию данных репозитория Subversion и произвел клонирование данных для создания локального репозитория Git.

Представим, что Alex изменяет файл размером в 1 MB в локальной копии данных из репозитория Subversion, после чего отправляет изменения в репозиторий. Локальная копия отражает последние модификации и локальные метаданные претерпевают изменения. В ходе инициированного Alex процесса добавления данных в централизованный репозиторий Subversion генерируется файл различий между предыдущим экземпляром файла и измененным файлом, после чего этот файл различий сохраняется в репозитории.

В подобной ситуации Git ведет себя диаметрально противоположно. В момент, когда Alex производит точно такую же модификацию эквивалентного файла в локальном клонированном репозитории, изменения сначала будут записаны только локально, после чего Alex сможет "поместить" ожидающие в локальном репозитории изменения в публичный репозиторий, сделав свою работу доступной для других участников процесса разработки проекта. Изменения данных сохраняются идентично в каждом репозитории Git, где они присутствуют. При изменении данных в локальном репозитории (в самом простом случае), локальный репозиторий Git создаст новый объект, представляющий измененный файл (со всеми данными файла внутри). Для каждой директории, находящейся выше измененного файла в дереве директорий (а также для корневой директории репозитория), новый объект дерева будет создан с новым идентификатором. Направленный ациклический граф создается в направлении от недавно созданного объекта корневой директории, указывающего на объекты данных (при этом повторно используются существующие ссылки на объекты данных в том случае, если содержимое файлов не было изменено в ходе данной операции изменения данных), а также указывающего на недавно созданный объект данных в месте, где был расположен предыдущий объект данных в предыдущей иерархии. (Объект данных (blob) представляет файл, хранимый в репозитории.)

В этот момент измененные данные все еще находятся в локальном клонированном Alex репозитории на его локальном устройстве хранения данных. Когда Alex "поместит" данные в репозиторий Git с публичным доступом, измененные данные будут отправлены в этот репозиторий. После того, как на стороне публичного репозитория будет проведена проверка того, могут ли измененные данные быть добавлены в ветвь, в публичном репозитории будут сохранены те же самые объекты, что были ранее созданы в локальном репозитории Git.

На самом деле в сценарии работы репозитория Git присутствует гораздо большее количество взаимодействующих систем, которые находятся на заднем плане и требуют от пользователя подтверждения намерения передачи изменений удаленному репозиторию вне зависимости от локальных механизмов отслеживания изменений. Однако, уровни усложнения архитектуры позволяют команде разработчиков получить в свое распоряжение более гибкую в отношении процессов разработки и публикации систему, как было описано выше в разделе "Начало развития проекта Git".

В сценарии использования системы Subversion участник процесса разработки не должен помнить о необходимости помещения данных в публичный удаленный репозиторий, когда данные готовы для обзора другими участниками. В момент, когда небольшая модификация файла большого размера отправляется в центральный репозиторий Subversion, хранение файлов различий гораздо эффективнее хранения всего содержимого файла каждой версии. Однако, как мы увидим позднее, существует обходной путь, который используется системой Git для решения этой проблемы.

6.4. Тулкит

На сегодняшний день экосистема проекта Git включает множество инструментов с интерфейсом командной строки и другими пользовательскими интерфейсами для работы под управлением множества операционных систем (включая ОС Windows, поддержка которой изначально была неполной). Большинство этих инструментов разработано с использованием основного тулкита Git.

Из-за того, что разработка Git на начальных этапах осуществлялась Linus Torvalds и ввиду связи последнего с сообществом разработчиков Linux, она осуществлялась в соответствии с философией создания тулкитов в большей степени в соответствии с традицией создания инструментов с интерфейсом командной строки для ОС семейства Unix.

Тулкит Git разделен на две части: вспомогательные (plumbing) и основные (porcelain) функции. Категория вспомогательных функций состоит из низкоуровневых команд, позволяющих осуществлять основные операции, связанные с отслеживанием состояния данных и манипуляциями с направленными ациклическими графами (DAG). Набор основных функций меньше и содержит команды git, которые скорее всего понадобятся большинству пользователей системы Git для управления репозиториями и осуществления обмена данными между репозиториями в ходе совместной работы.

Хотя архитектура тулкита и позволила реализовать достаточное количество команд для предоставления доступа к большинству функций разработчикам сценариев, разработчики приложений жаловались на отсутствие пригодной для связывания библиотеки Git. Так как исполняемый файл Git вызывает функцию die() код не является реентерантным, поэтому графические и веб-интерфейсы, а также длительно работающие службы должны вызывать функции fork/exec для запуска бинарного файла Git, что может замедлить работу приложения.

Для исправления ситуации, с которой столкнулись разработчики приложений, была проведена работа; если вас интересуют подробности, обратитесь к разделу "Что дальше?".

6.5. Репозиторий, данные индексирования и рабочие области

Давайте приступим к непосредственному углубленному исследованию Git на примере локального репозитория для понимания всего лишь нескольких фундаментальных концепций.

Вначале для создания нового инициализированного репозитория Git в нашей локальной файловой системе (при условии использования Unix-подобной операционной системы) мы можем выполнить следующие команды:

$ mkdir testgit
$ cd testgit
$ git init

Теперь в нашем распоряжении пустой, но инициализированный Git-репозиторий, расположенный в директории testgit. Мы можем создавать ветви, добавлять данные, создавать тэги и даже обмениваться данными с другими локальными или удаленными репозиториями Git. Возможен даже обмен данными с репозиториями других типов систем контроля версий при условии использования небольшого набора специальных команд приложения git.

Команда git init создает поддиректорию .git в директории testgit. Давайте рассмотрим ее содержимое:

tree .git/
.git/
|-- HEAD
|-- config
|-- description
|-- hooks
|   |-- applypatch-msg.sample
|   |-- commit-msg.sample
|   |-- post-commit.sample
|   |-- post-receive.sample
|   |-- post-update.sample
|   |-- pre-applypatch.sample
|   |-- pre-commit.sample
|   |-- pre-rebase.sample
|   |-- prepare-commit-msg.sample
|   |-- update.sample
|-- info
|   |-- exclude
|-- objects
|   |-- info
|   |-- pack
|-- refs
    |-- heads
    |-- tags

Находящаяся на верхнем уровне директория .git по умолчанию является поддиректорией корневой рабочей директории testgit. Она содержит несколько различных типов файлов и директорий:

Директория .git на самом деле является репозиторием. Директория, в которой хранится набор рабочих файлов, является рабочей директорией (working directory), которая обычно является родительской для директории .git (или для репозитория). В том случае, если бы вы создавали удаленный репозиторий Git, в котором не было рабочей директории, вам пришлось бы инициализировать его с помощью команды git init --bare. Эта команда позволила бы просто создать директорию репозитория непосредственно в корневой директории вместо создания репозитория в форме поддиректории рабочей директории с файлами.

Другим очень важным файлом является файл индексирования Git (Git Index): .git/index. Он позволяет создать временное рабочее пространство между локальной рабочей директорией и локальным репозиторием. Индексирование подразумевает хранение данных специфичных изменений в одном файле (или в большем количестве файлов), предназначенных для последующего применения. Даже в том случае, если вы вносите изменения, относящиеся к разным типам функций, эти изменения могут быть внесены в исходный код в рамках единственной операции, сопровождающейся подробным пояснением, позволяющим логически разделить их. Для выборочного сохранения определенных изменений в файле или наборе файлов вы можете использовать команду git add -p.

Данные изменений Git по умолчанию хранятся в единственном файле, расположенном в директории репозитория. Пути к этим трем пространствам хранения данных в рамках дерева директорий могут задаваться с помощью переменных окружения.

Полезно понимать принципы взаимодействий, осуществляющихся между этими тремя пространствами (репозиторием, пространством индексирования и рабочим пространством) в процессе выполнения нескольких основных команд Git:

Давайте установим более конкретное значение этих утверждений, исследовав содержимое файлов в директории .git (или репозитории).

$ GIT_DIR=$PWD/.git
$ cat $GIT_DIR/HEAD

ref: refs/heads/master

$ MY_CURRENT_BRANCH=$(cat .git/HEAD | sed 's/ref: //g')
$ cat $GIT_DIR/$MY_CURRENT_BRANCH

cat: .git/refs/heads/master: No such file or directory

Мы столкнулись с ошибкой из-за того, что перед добавлением любых данных в репозиторий Git не задается никаких ветвей кроме стандартной для Git ветви с именем master, причем самой ветви может и не существовать.

Теперь, когда мы снова попытаемся добавить данные, ветвь master будет создана по умолчанию для выполнения данной операции. Давайте выполним операцию (продолжая работу в той же командной оболочке для сохранения истории команд и контекста):

$ git commit -m "Initial empty commit" --allow-empty
$ git branch

* master

$ cat $GIT_DIR/$MY_CURRENT_BRANCH

3bce5b130b17b7ce2f98d17b2998e32b1bc29d68

$ git cat-file -p $(cat $GIT_DIR/$MY_CURRENT_BRANCH)

Здесь мы наблюдаем представление данных в рамках базы данных объектов Git.

6.6. База данных объектов

Объекты Git
Рисунок 6.2: Объекты Git

В Git используются 4 основных типа примитивных объектов для каждого из типов данных, на основе которых строится локальный репозиторий. Объект каждого типа имеет следующие атрибуты: тип (type), размер (size) и данные (content). Примитивные типы объектов:

Для ссылок на все примитивные объекты используются SHA-хэши, являющиеся идентификаторами объектов из 40 цифр, которые имеют следующие свойства:

Два первых свойства SHA-хэшей, относящиеся к установлению идентичности объектов, полезны для реализации распределенной модели Git (вторая задача системы Git). Последнее свойство позволяет организовать систему защиты от повреждения данных (третья задача Git).

Несмотря на желаемые результаты использования хранилища на основе направленных ациклических графов для хранения данных и истории объединений ветвей, для многих репозиториев хранилище на основе данных отличий файлов окажется более эффективным в плане использования дискового пространства, нежели хранилище на основе отдельных объектов, представленных направленными ациклическими графами.

6.7. Техники хранения и сжатия данных

Git решает проблему чрезмерного потребления дискового пространства путем сжатия объектов, причем файл индексирования данных используется для указания на сдвиги, по которым находятся определенные объекты в соответствующем упакованном файле.

Диаграмма упакованного файла с соответствующим файлом индексирования
Рисунок 6.3: Диаграмма упакованного файла с соответствующим файлом индексирования

Мы можем подсчитать количество отдельных (или не сжатых) объектов в локальном репозитории Git с помощью команды git count-objects. После этого мы можем упаковать отдельные объекты средствами Git в рамках базы данных объектов, удалить уже упакованные отдельные объекты и обнаружить лишние упакованные файлы с помощью вспомогательных команд Git в случае необходимости.

Формат файла упаковки объектов эволюционировал с начального уровня, представленного форматом с сохранением контрольных сумм для упакованного файла и файла индексирования в самом файле индексирования. Однако, данный подход допускал возможность неустанавливаемого повреждения сжатых данных, так как фаза повторной упаковки не предусматривала каких-либо дополнительных проверок. Во 2 версии формата файла упаковки объектов эта проблема была преодолена путем включения контрольных сумм для каждого из сжимаемых объектов в файл индексирования. Версия 2 также позволила создавать упакованные файлы размером более 4 ГБ, которые не поддерживались в начальной версии. Для быстрого установления факта повреждения файла упаковки объектов в его конце должен быть записан 20-байтный хэш SHA-1 для упорядоченного списка всех хэшей SHA объектов из этого файла. В новом формате файла упаковки объектов наибольшее внимание было уделено достижению второй цели проектирования, а именно защите данных от повреждения.

При удаленном взаимодействии Git подсчитывает объем добавленных и содержащихся в репозитории данных, которые должны быть переданы по сети для синхронизации репозиториев (или только ветви) и в процессе работы генерирует файл упаковки объектов, который должен быть отправлен назад с использованием выбранного клиентом протокола.

6.8. История объединения ветвей

Как было сказано ранее, подход системы Git к хранению истории объединения ветвей фундаментально отличается от подхода семейства систем контроля версий на основе RCS. Система Subversion, например, представляет историю изменения файла или дерева директорий в виде линейной последовательности; в этом случае любой элемент файловой системы с большим номером ревизии будет заменять элемент с меньшим номером ревизии. Непосредственное выделение ветвей не поддерживается и может осуществляться только путем самостоятельного создания специальной структуры директорий в репозитории.

Диаграмма, показывающая ход процесса объединения ветвей
Рисунок 6.4: Диаграмма, показывающая ход процесса объединения ветвей

Давайте используем пример для демонстрации проблем, которые могут возникать при поддержке множества ветвей для одной и той же работы. После этого мы рассмотрим сценарий, показывающий возникающие ограничения.

При работе с "ветвью" в системе Subversion в рамках стандартной корневой директории branches/branch-name мы работаем с деревом поддиректорий директории trunk (обычно в эквивалентной директории располагается код ветви master). Предположим, что в рамках этой ветви производится параллельная разработка программного компонента, изначально разрабатываемого в рамках директории trunk.

К примеру, мы можем изменять кодовую базу приложения для поддержки другого типа базы данных. После частичной реализации необходимых функций у нас может возникнуть желание добавить в основную ветвь разработки код из поддиректории другой ветви (не относящийся к коду из директории trunk). Мы объединим изменения, прибегнув в случае необходимости к ручной правке, и продолжим нашу разработку. В конце концов мы закончим реализацию нашего кода для миграции на отличный тип базы данных в рамках ветви branches/branch-name и объединим код ветви с кодом из директории trunk. Проблема, с которой столкнутся пользователи таких систем контроля версий, как Subversion с линейным представлением истории изменений, заключается в том, что не существует способа получения информации о том, какие какие изменения из других ветвей были добавлены в код из директории trunk ранее.

Системы контроля версий, записывающие историю объединений ветвей с помощью направленных ациклических графов, такие, как Git, обрабатывают подобные сценарии достаточно хорошо. Предполагая, что другая ветвь не содержит изменений, которые были внесены в нашу ветвь с реализацией кода миграции на другой тип базы данных (скажем, ветви db-migration из нашего репозитория Git), на основе взаимосвязей родительских объектов мы можем установить что изменения, внесенные в ветвь db-migration содержали ссылку (tip или HEAD) на другую ветвь, в которой ведется основная разработка. Следует отметить, что объект добавления данных может не иметь или иметь произвольное количество (ограниченное только возможностями объединяющего ветки пользователя) родительских объектов. Следовательно, в случае объединения ветвей ветвь db-migration получит информацию о том, будет ли она объединяться с текущей ветвью или с ветвью, в рамках которой производится основная разработка, получив хэши SHA родительских объектов. Это же утверждение актуально и для объединения с ветвью master (эквивалентом директории trunk при работе с системой Git).

Вопрос, на который сложно ответить при использовании истории объединений ветвей на основе направленных ациклических графов (и линейных представлений) заключается в том, какие изменения содержатся в каждой из ветвей. Например, в сценарии выше мы предположили, что все изменения из обоих рассматриваемых ветвей были внесены в каждую из ветвей. Но это не всегда так.

При работе в более простых ситуациях Git может извлекать изменения из других ветвей и вносить их в текущую ветвь, но только в том случае, если изменение может быть непосредственно применено без дополнительного редактирования.

6.9. Что дальше?

Как говорилось ранее, основная часть системы Git в сегодняшнем виде создана в соответствии с философией архитектуры тулкита из мира Unix, которая очень хорошо подходит для сценариев, но менее полезна в случае интеграции или связывания с приложениями или службами, работающими в течение длительного периода времени. Хотя поддержка Git на сегодняшний день реализована в множестве интегрированных сред разработки, работа по добавлению кода поддержки и его сопровождению оказывается более сложной, чем работа, связанная с интеграцией кода поддержки других систем контроля версий, которые предоставляют библиотеки для множества платформ, которые упрощают связывание кода и разделение функций.

Для решения этой проблемы Shawn Pearce (из подразделения Google Open Source Programs Office) возглавил проект по созданию библиотеки Git, которая могла связываться с кодом приложений и распространялась под более либеральной лицензией, не препятствующей ее использованию. Эта библиотека получила имя libgit2. Она не получила заметного распространения до того момента, как студент по имени Vincent Marti не выбрал ее для работы в рамках проекта Google Summer of Code в прошлом году. С того момента Vincent Marti и инженеры компании Github продолжили работу над проектом libgit2 и создали биндинги для множества других популярных языков программирования, таких, как Ruby, Python, PHP, языки .NET, Lua и Objective-C.

Shawn Pearce также начал разработку библиотеки с именем JGit на языке Java, которая распространялась под лицензией BSD и поддерживала большое количество стандартных операций, выполняемых при работе с репозиториями Git. На данный момент развитие этой библиотеки поддерживается организацией Eclipse Foundation и она используется для работы среды интегрированной разработки Eclipse с системой Git.

Другие интересные и экспериментальные проекты с открытым исходным кодом, разрабатываемые вне основного проекта Git представлены множеством реализаций, использующих альтернативные хранилища данных в качестве систем организации базы данных объектов Git, среди которых можно выделить:

Все эти проекты с открытым исходным кодом развиваются независимо от основного проекта Git.

Как вы видите, на сегодняшний день существует большое количество методов использования формата хранения данных, применяемого в Git. Лицом проекта Git сейчас является не только интерфейс командной строки тулкита, развиваемого в рамках основного проекта Git; это также формат репозитория и протокол, используемый для осуществления обмена данными между репозиториями.

В момент написания этой главы большинство названных проектов, в соответствии с заявлениями их разработчиков, не достигло стабильных релизов, поэтому все еще требуется выполнение некоторого объема работы в этой области, но будущее проекта Git все равно кажется многообещающим.

6.10. Выученные уроки

При разработке программного обеспечения любое архитектурное решение по своей сути является компромиссом. Наблюдая с позиции опытного пользователя системы контроля версий Git, а также того, кто разрабатывает программное обеспечение для работы с моделью базы данных объектов Git, я испытываю гордость по поводу сегодняшнего состояния системы Git. Система стала такой благодаря урокам, извлеченным при исправлении вызывающих наибольшее количество повторяющихся жалоб недоработок, возникающих в результате принятия архитектурных решений разработчиками основной части проекта Git.

Одной из наиболее часто возникающих жалоб от разработчиков и руководителей проектов, которые используют Git, является жалоба на отсутствие интеграции с интегрированными средами разработки, реализованной так же хорошо, как и в случае других инструментов контроля версий. Архитектура тулкита, выбранная при реализации Git, сделала эту работу более сложной, чем работа по интеграции других современных систем контроля версий с интегрированными средами разработки и связанными с ними инструментами.

Ранее некоторые команды Git были реализованы в форме сценариев оболочки. Эти реализации команд в форме сценариев сделали систему Git менее переносимой, особенно на платформу Windows. Я уверен, что разработчики основной части проекта Git не упустили из вида этот факт, но он в конечном счете негативно повлиял на внедрение Git в крупных организациях ввиду распространенных сложностей, возникавших при переносе системы на другие платформы на ранних стадиях разработки Git. Сегодня существует проект "Git for Windows", реализуемый добровольцами с целью своевременного переноса новых версий Git на платформу Windows.

Косвенным последствием проектирования Git на основе архитектуры тулкита с большим набором вспомогательных команд является неуверенность новых пользователей, которая возникает почти сразу же после начала использования системы; эта неуверенность возникает ввиду множества факторов от растерянности при виде всех доступных вспомогательных команд до непонимания сообщений об ошибках, выводимых после некорректного завершения низкоуровневой вспомогательной задачи, при этом существует еще очень много областей, в которых новые пользователи могут сбиться с пути. Это обстоятельство осложнило внедрение Git некоторыми командами разработчиков.

Даже при наличии всех этих жалоб на систему Git, я очень вдохновлена возможностями, которые откроются в ходе процесса разработки проекта Git в будущем, а также в ходе разработки всех связанных проектов с открытым исходным кодом, которые были начаты благодаря появлению Git.

7.1. Почему существует проект GPSD

GPSD существует из-за того, что прикладные протоколы, поставляемые с датчиками GPS и другие датчики, относящиеся к навигации, плохо спроектированы, недостаточно документированы и сильно зависят от типа и модели датчика. Подробное обсуждение смотрите по ссылке [Ray]; там, в частности, вы узнаете о капризах стандарта NMEA 0183 (варианта стандарта пакетов сообщений GPS) и о бессистемной куче плохо документированных протоколов поставщиков, которые с ним конкурируют.

Если бы приложения справлялись со всей этой сложностью самостоятельно, то результатом было бы огромное количество нестабильного и дублирующего кода, что ведет к высокой степени наличия видимых пользователю дефектов и постоянным проблемам, т. к. аппаратура постепенно становилась бы несовместимой с приложениями.

Проект GPSD изолирует приложения, обрабатывающие данные о местоположении, от деталей аппаратного интерфейса благодаря тому, что в самом проекте известно обо всех протоколах (во время написания статьи мы поддерживали около 20 различных протоколов), и благодаря такому управлению последовательными устройствами и устройствами USB, что приложениям этого делать уже не требуется, а достаточно только получать информацию от датчиков в простом и независимом от устройств формате JSON. GPSD еще больше упрощает жизнь благодаря тому, что предоставляются клиентские библиотеки и клиентским приложениям даже не нужно знать об этом формате сообщений. Вместо того, чтобы получать информацию с датчиков, достаточно просто вызвать процедуру.

Система GPSD также поддерживает хранение точного времени; она может выступать в качестве источника времени для ntpd (демона протокола сетевого сервиса времени), если в каком-нибудь из подключенных к нему датчиков имеется возможность выдавать сигналы PPS (секундные сигналы). Разработчики проекта GPSD тесно сотрудничают с проектом ntpd с целью улучшения сетевого сервиса времени.

В настоящее время (середина 2011 года) мы работаем над завершением поддержки сети AIS морских навигационных приемников. В будущем мы ожидаем, что будем поддерживать новые виды датчиков, работающие с информацией о местоположении, такие как приемники авиационных систем «запрос-ответ» второго поколения, поскольку станут доступными документация по протоколу и тестовые экземпляры устройств.

Таким образом, самым важным аспектом в проекте GPSD является сокрытие всех аппаратно-зависимых безобразий за простым клиентским интерфейсом, не требующим для получения данных никаких настроек.

7.2. Взгляд извне

Основной программой в наборе инструментальных средств GPSD является демон сервиса gpsd. Он может собирать данные, поступающие от набора подключенных датчиков устройств через соединения RS232, USB, Bluetooth, TCP/IP и UDP. Данные обычно поступают на порт 2947 на TCP/IP, но также могут поступать через совместно используемую память или интерфейс D-BUS.

Пакет GPSD поставляется с клиентскими библиотеками для языков C, C++ и Python. Он включает в себя образцы клиентских приложений на языках C, C++, Python и PHP. Клиентская сборка для Perl доступна через CPAN. Эти клиентские библиотеки не только удобны для разработчиков приложений; они также спасают разработчиков GPSD от головной боли, изолируя приложения от деталей сообщений JSON пакета GPSD. Таким образом, интерфейс API, предоставляемый клиентам, может оставаться такими же даже в случае, когда в протокол для новых типов датчиков добавляются новые возможности.

К числу других программ в наборе инструментальных средств относятся утилита низкоуровневого мониторинга устройств (gpsmon), профилировщик, который создает отчеты о статистике ошибок и синхронизации устройств (gpsprof), утилита настройки устройств (gpsctl), а также программа для потокового преобразования журнальных данных в удобочитаемый формат JSON (gpsdecode). Вместе они помогают технически подкованными пользователями заглянуть как можно глубже в работу присоединенных датчиков, работе которых у них вызывает беспокойство.

Конечно, эти инструментальные средства также помогают самим разработчикам проекта GPSD проверять правильность работы gpsd. Одним из самых важных тестовых инструментальных средств является gpsfake, средство тестирования gpsd, которое можно подключить к журнальным файлам любого количества датчиков так, как будто бы они являются реальными устройствами. С помощью gpsfake мы можем повторно запустить журнальный файл датчика, полученный вместе с сообщением об отказе, и воспроизводить конкретные проблемы. gpsfake также является движком нашего обширного инструментального средства, предназначенного для регрессионных тестов, которое снижает затраты на модификацию программного обеспечения благодаря более простому обнаружению изменений, из-за которых возникает проблема.

Одним из самых важных уроков, который, как мы думаем, мы получили для будущих проектов, состоит в том, что недостаточно, чтобы набор инструментальных средств был корректным, должна также быть возможность продемонстрировать его правильность. Мы обнаружили, что, когда эта цель реализуется должным образом, она будет не маскировочной сеткой, а парой крыльев — время, которое мы затрачиваем на написание нагрузочных и регрессионных тестов, будет многократно окупаться сторицей благодаря той свободе, которая позволяет нам изменять код, не опасаясь, что мы создадим трудно обнаруживаемый беспорядок в существующих функциональных возможностях.

7.3. Слои программного обеспечения

Внутри GPSD происходит гораздо больше того, чем могут предположить люди, знающие на практике, что нужно «подключить датчик и он просто заработает». Внутренняя структура gpsd естественным образом делится на четыре части: драйверы, анализатор пакетов, основную библиотеку и мультиплексор. Мы опишем эти части, рассматривая их снизу вверх.

Рис.7.1: Слои программного обеспечения

Драйверы являются, в сущности, драйверами устройств пользовательского пространства для каждого вида чипсетов датчиков, которые мы поддерживаем. Основным их ключевыми элементами являются методы анализа пакетов данных и получения информации о времени, позиции скорости или состоянии датчиков, изменения их режимов работы или скорости передачи данных, определения подтипов устройств и т.д. Вспомогательные методы могут поддерживать операции управления драйверами, например, изменение скорости последовательного обмена данными с устройством. Весь интерфейс драйвера является структурой на языке C, заполненной указателями на данные и методы, которая преднамеренно имитирует структуру драйверов устройств в Unix.

Анализатор пакетов отвечает за получение пакетов данных из последовательных входных потоков. Это, большей частью, машина состояний или конечный автомат, который наблюдает за всем, что выглядит как один из наших 20 или около того известных типов пакетов (в большинстве из них есть контрольная сумма, так что когда мы говорим, что обнаружили один из них, мы говорим это с высокой уверенностью). Поскольку устройства могут подключаться в режиме горячей замены или изменять режимы, тип пакета, поступающего из последовательного порта или порта USB, не обязательно постоянно будет совпадать с типом первого пакета.

Основная библиотека (core library) осуществляет управление сессией работы с устройством-датчиком. Основными ключевыми аспектами являются следующие:

Ключевой особенностью основной библиотеки является то, что она отвечает за то, чтобы для каждого подключения GPS использовался правильный драйвер устройства, зависящий от типа пакетов, возвращаемого анализатором пакетов. Это не настраивается заранее и может изменяться с течением времени, особенно если в устройстве есть возможность переключения между различными протоколами (в большинство чипсетов GPS поддерживается протокол NMEA и один или несколько двоичных протоколов, предоставляемых поставщиком, а а такие устройства, как AIS, могут по одному и тому же проводу пересылать пакеты, использующие два различных протокола).

Наконец, мультиплексор является частью демона, который работает с сессиями клиентов и назначением устройств. Он отвечает за передачу сообщений клиентам, принимая команды клиента и реагируя на сообщения о горячих подключениях. В сущности, все это задается в одном исходном файле gpsd.c и никакое из заданий не обращается к драйверам устройств напрямую.

Первые три компонента (кроме мультиплексора) компонуются друг с другом в библиотеке с названием libgpsd и ими можно пользоваться отдельно от мультиплексора. Наши прочие инструментальные средства, с помощью которых происходит непосредственное общение с датчиками, например, gpsmon и gpsctl, осуществляют это с помощью непосредственного обращения к основной библиотеке и слою драйверов.

Наиболее сложным отдельным компонентом является анализатор пакетов, имеющий размер около двух тысяч строк кода. Их количество сократить нельзя; конечный автомат, который может распознавать такое количество различных протоколов, как это он делает, обязательно будет большим и запутанным. К счастью, анализатор пакетов также легко изолировать и протестировать; проблемы, имеющиеся в нем, не будут влиять на другие части кода.

Слой мультиплексора имеет приблизительно такой же размер, но он немного менее запутанный. Основную часть демона составляют драйверы устройств - около 15 тыс. строк. Оставшаяся часть кода - все средства и библиотеки поддержки вместе с инструментальными средствами тестирования клиентов - вместе по размеру равны примерно размеру демона (некоторый код, в частности парсер JSON, используется совместно как в демоне, так и в клиентских библиотеках).

Преимущество такого подхода, использующего слои, демонстрируется несколькими способами. Во-первых, поскольку драйверы для новых устройств писать легко, некоторые из них были отданы на разработку лицам, не входящим в основную команду разработчикам: интерфейс API драйвера документирован, а отдельные драйверы подключаются к основной библиотеке только через указатели, расположенные в главной таблице типов устройств.

Еще одним преимуществом является то, что системные интеграторы могут резко сократить размер GPSD при использовании во встроенных системах, просто решив не компилировать неиспользуемые драйверы. Демон для начала не так уж и велик и сборка, урезанная соответствующим образом, работает вполне нормально на маломощных низкоскоростных устройствах ARM, не обладающих большим количеством памяти. ARM является RISC архитектурой с 32-битным набором команд, используемой в мобильных устройствах и во встраиваемой электронике. Смотрите http://en.wikipedia.org/wiki/ARM_architecture.

Третье преимущество слоев состоит в том, что мультиплексор демона может быть отсоединен от основной библиотеки и заменен простой логикой, например, такой, которая в потоке преобразует сообщения, поступающие в журнальный файл, в сообщения JSON, т.е. именно то, что делает утилита gpsdecode.

В этой части архитектуры GPSD нет ничего нового. Ее урок состоит в том, что сознательное и строгое применение шаблона проектирования, предназначенного для устройств Unix, приносит пользу не только в ядре ОС, но и в пользовательских программах, в которых они также необходимы при работе с разнообразными аппаратными средствами и протоколами.

7.4. Поток данных

Теперь мы рассмотрим архитектуру GPSD с точки зрения потока данных. В режиме нормальной работы gpsd представляет собой цикл, ожидающий входных данных от одного из следующих источников:

  1. Набора клиентов, передающих запросы через порт TCP/IP.
  2. Набора навигационных датчиков, подключенных через последовательные устройства или устройства USB.
  3. Специального управляющего сокета, используемого скриптами горячего подключения и некоторыми конфигурационными инструментальными средствами.
  4. Некоторого количества серверов, периодически выдающих корректирующие сообщения для GPS (DGPS и NTRIP). Эти сообщения будут обрабатываться точно также, как если бы они поступали от навигационных датчиков.

Когда порт USB активируется устройством, которое может быть навигационным датчиком, скрипт горячего подключения (поставляется с GPSD) отправляет уведомление на управляющий сокет. Это сигнал для слоя мультиплексора поместить устройство в свой внутренний список датчиков. И, наоборот, событие, связанное с удалением устройства, может удалять устройство из этого списка.

Когда клиент выдает запрос о том, что наступило время, слой мультиплексора открывает навигационные датчики в своем списке и начинает принимать от них данные (добавляя дескрипторы их файлов в набор в вызове main). В остальных случаях все устройства GPS закрыты (но остаются в списке) и демон находится в покое. Для устройств, которые прекратили отправку данных, в списке устройств устанавливается таймаут.

Рис.7.2: Поток данных

Когда данные поступают от навигационного датчика, они подаются в анализатор пакетов - конечный автомат, который работает как лексический анализатор компилятора. Работа анализатор пакетов состоит в накапливании данных из каждого порта (по отдельности) и определении, когда накопленные данные станут представлять собой пакет известного типа.

В пакете могут находиться данные GPS, указывающие местоположение, датаграммы marine AIS, данные с датчика магнитного компаса, широковещательные пакеты DGPS (дифференциальная GPS) или некоторые другие данные. Анализатор пакетов не заботится о содержании пакета; все, что он делает, это сообщает основной библиотеке, что он накопил данные и передает пакет и тип пакета.

Затем основная библиотека перенаправляет пакет в драйвер, связанный с этим типом пакетов. Работа драйвера состоит в получении данных из пакета и помещения их в структуру, созданную в рамках сессии для конкретного устройства, а также установке некоторых битов состояния, сообщающих слою мультиплексора о том, какие данные он получил.

Один из этих битов указывает на то, что демон собрал достаточно данных для того, чтобы отправить ответ своим клиентам. Когда этот бит устанавливается после считывания данных с датчика устройства, то это означает, что мы достигли конца пакета, конца группы пакетов (которая может состоять из одного или нескольких пакетов), и данные, находящиеся в сессионной структуре устройства, должны быть переданы в один из механизмов экспорта.

Основным экспортным механизмом является «сокет»; он создает объект сообщения в формате JSON и отправляет его всем клиентам, наблюдающим за устройством. Есть еще экспортный механизм с совместно используемой памятью, куда копируются данные вместо того, чтобы копировать их в совместно используемую память сегмента. В любом из этих случаев, предполагается, что клиентская библиотека распакует данные в структуру в памяти клиентской программы. Также доступен третий экспортный механизм, который сообщает о новом местоположении через шину DBUS.

Код GPSD распределен по горизонтали так же тщательно, как и по вертикали. Анализатор пакетов не знает и не должен знать ничего о том, чем нагружен пакет, и не должен беспокоиться о том, является ли его источником порт USB, устройство RS232, радиоканал Bluetooth, псевдо-терминал tty, соединение с сокетом TCP или поток пакетов UDP. В драйверах известно, чем загружен пакет, но ничего не известно о внутренних особенностях анализа пакетов и о экспортных механизмах. Экспортные механизмы рассматриваются только как сессионные структуры данных, обновляемые драйверами.

Такое разделение функций очень хорошо служит проекту GPSD. Например, когда в начале 2010 года мы получили запрос адаптировать код так, чтобы он для бортовой навигационной системы робота - подводной лодки принимал данные от датчика, поступающие в виде пакетов UDP, это удалось легко сделать с помощью нескольких строк кода без нарушения последующих этапов конвейера обработки данных.

В более общем смысле тщательное разделение на слои и модули позволило относительно легко добавлять новые типы датчиков. Мы добавляем новые драйверы приблизительно каждые шесть месяцев; причем некоторые из них были написаны людьми, которые не входят в состав основных разработчиков.

7.5. Защищая архитектуру

По мере того как развиваются такие программы с открытым исходным кодом, как gpsd, одной из повторяющихся проблем становится проблема, связанная с тем, что каждый участник для того, чтобы решить его конкретную проблему, будет делать то, что вызовет постепенное перемещение все большего количества информации между слоями или стадиями, между которыми первоначально были установлены строгие границы.

На момент написания статьи мы обеспокоены тем, что, возможно, некоторую информацию о типе источника входных данных (USB, RS232, pty, Bluetooth, TCP, UDP) потребуется передавать в слой мультиплексора для того, чтобы ему сообщить, должны ли, например, в неопознанное устройство посылаться строки, позволяющие опознать устройство. Такие строки иногда необходимы для того, чтобы «разбудить» датчики RS232C, однако есть веские причины не отправлять их на любые другие устройства, где они не нужны. Многие устройства GPS и другие датчики создавались с малым бюджетом и в спешке; некоторые из них можно ввести в состояние ступора, если посылать им неожиданные управляющие строки.

По аналогичной причине в демоне есть параметр -b, который запрещает демону изменять скорость обмена данными во время цикла анализа, выполняемого анализатором пакетов. Некоторые плохо сделанные устройства Bluetooth справляются с такими ситуациями настолько плохо, что для того, чтобы их вновь заставить работать, их надо перезапустить при помощи отключения питания; в экстремальном случае пользователю для того, чтобы сбросить блокировку датчику, потребовалось в действительности отпаять батарейку резервного питания!

Оба этих случая являются необходимыми исключениями из общих правил разработки проекта. Однако, гораздо более обычны случаи, когда такие исключения ведут к плохим ситуациям. Например, у нас были некоторые патчи, предназначенные для того, чтобы служба времени PPS работала лучше, которые нарушали деление на вертикальные слои, что сделало невозможным PPS правильно работать с более чем с одним драйвером, для помощи которым патчи и были предназначены. Мы отказались от них и использовали более сложные устройства, которым не требовались улучшения.

Однажды несколько лет назад нас попросили о поддержке устройства GPS со странной особенностью, состоящей в том, что контрольные суммы в их пакетах NMEA могут быть неправильными в тех случаях, когда устройство не смогло определить местоположение. Для поддержки этого устройства, мы должны были бы либо (а) отказаться от проверки контрольных сумм для любых входящих данных, которые были похожи на пакеты NMEA, рискуя тем, что анализатор пакетов мог передавать мусор в драйвер NMEA, либо (б) добавить параметр командной строки, в котором принудительно указывается тип датчика.

Руководитель проекта (автор этой главы) отказался и от первого и от второго. Очевидно, что отказ от проверки пакетов NMEA был плохим решением. Но переключатель, в котором принудительно указывается тип датчика, было бы приглашением с леностью относиться к правильному автоматическому конфигурированию, что вызвало бы проблемы на всем пути вплоть до клиентских приложений GPSD и их пользователей. Следующим шагом по этому пути, вымощенному благими намерениями, наверняка был бы переключатель скорости обмена данными. Вместо этого, мы отказались поддерживать такое неисправное устройство.

Одной из самых важных обязанностей ведущего архитектора проекта является защита архитектуры от разумных «исправлений», который разрушают проект и будут причиной проблем его работы или сильной головной боли при его обслуживании в будущем. Аргументы, касающиеся этого, могут быть довольно горячими, особенно когда конфликт, связанный с защитой архитектуры, касается какой-либо функции, которая, по мнению разработчика или пользователя, считается незаменимой. Но этакие аргументы необходимы, поскольку самый простой вариант часто оказывается неправильным в более долгосрочной перспективе.

7.6. Нет конфигурирования — нет суеты

Чрезвычайно важной особенностью демона gpsd является то, что в нем отсутствуют средства конфигурирования (с одним небольшим исключением для устройств Bluetooth с испорченной прошивкой). Нет настроечного файла! Демон определяет типы датчиков, с которыми он общается, путем прослушивания входных данных. Для устройств RS232 и USB gpsd даже автоматически определяет скорость обмена данными (то есть, автоматически определяет скорость линии последовательного доступа), так что демону нет необходимости заранее знать скорость/четность/стоповые биты, с которыми датчик подает информацию.

Если в операционной системе, в которой установлен демон, есть возможность горячего подключения, то скрипты горячего подключения могут отправлять сообщения об активации и деактивации устройств в управляющий сокет, который уведомит демон об изменении в его окружении. В дистрибутиве GPSD такие скрипты поставляются для Linux. Результатом является то, что конечные пользователи могут подключить USB-устройство GPS к своему ноутбуку и ожидать, что оно немедленно начать передавать сообщения, которые смогут прочитать приложения, определяющие местоположение - без путаницы, без суеты и без редактирования настроечного файла или реестра свойств.

Преимущества такого подхода распространяется вплоть до стека приложений. Кроме всего прочего, это значит, что пока все это работает, в приложениях, определяющих местоположение, может не быть конфигурационной панели, связанной с настройками GPS и порта. Это экономит много усилий у тех, кто пишет приложения, а также у пользователей: они могут рассматривать определение местоположения как сервис, который почти настолько прост, как системные часы.

Одно из следствий философии отсутствия конфигурационных средств состоит в том, что мы не принимаем предложения о добавлении конфигурационного файла или дополнительных параметров командной строки. Проблема в том, что конфигурационный файл, который можно редактировать, нужно будет редактировать. Это значит, что для конечных пользователей добавляются хлопоты, связанные с настройкой, т. е. с тем, чего следует избегать в демоне хорошо спроектированного сервиса.

Разработчики GPSD являются специалистами системы Unix, работающими согласно глубоко укоренившейся традиции Unix, согласно которой конфигурационные средства и наличие большого количества регуляторов близко к религии. Тем не менее, мы думаем, что в проектах с открытым исходным кодом можно попытаться сделать намного больше, выбросив настроечные файлы и автоматически выполняя конфигурирование в в зависимости от того, в какой среде происходит действие.

7.7. Ограничения, имеющиеся во встроенных системах, полезны

С 2005 года основной задачей проекта GPSD были разработки встраиваемых систем. Первоначально это было связано с тем, что в нас были сильно заинтересованы системные интеграторы, работающие с одноплатными компьютерами, но затем это проявилось неожиданным образом: установками на смартфонах с поддержкой GPS. Однако, согласно отчетам нашими самыми любимыми встроенными системами все еще остаются роботы-подводные лодки.

Проектирование встраиваемых решений повлияло на GPSD в важных направлениях. Для того, чтобы код хорошо работал в низкоскоростных системах с небольшим количеством памяти с низким энергопотреблением, мы много размышляем об экономии используемой памяти и не сильной загрузке процессора.

Одним из важных аспектов в этом вопросе является, как уже упоминалось, возможность обеспечивать, чтобы в сборках gpsd не было ничего лишнего, кроме определенного набора протоколов для датчиков, которые должен поддерживать системный интегратор. В июне 2011 года для минимальной статической сборки gpsd для системы x86 необходим был объем памяти равный приблизительно 69K (это со прикомпонованными необходимыми стандартными библиотеками на C) на 64-разрядной архитектуре x86. Для сравнения, статическая сборка со всеми драйверами равна приблизительно 418K.

Другая особенность состоит в том, что мы профилируем хотспоты CPU несколько иначе, чем в большинстве проектов. Поскольку датчики месторасположения, как правило, передают лишь небольшие объемы данных с интервалом порядка 1 секунды, производительность в обычном смысле не является проблемой для проекта GPSD - даже крайне неэффективный код вряд ли будет настолько увеличивать задержки, что они будут видны на уровне приложений . Вместо этого, наши усилия направлены на уменьшение использования ресурсов процессора и сокращение энергопотребления. В этом вопросе мы оказались довольно успешными: даже на маломощных системах ARM без FPU, доля ресурсов процессора, потребляемая gpsd, была снижена приблизительно до уровня шума, вносимого профилировщиком.

Хотя разработка кода, мало занимающего память и с хорошей энергоэффективностью, является в данный момент в значительной степени решенной проблемой, есть еще один аспект, стремление к которому во встроенных системах все еще создает напряженность в архитектуре GPSD: использование скриптовых языков. С одной стороны, мы хотим минимизировать дефекты, связанные с низким уровнем управления ресурсами, удаляя из языка C столько кода, сколько это возможно. С другой стороны, язык Python (предпочитаемый нам скриптовый язык) для большинства встроенных систем просто слишком тяжеловесен и медлителен.

Мы решаем этот компромисс очевидным образом: демон сервиса gpsd написан на языке C, в то время как фреймворк тестирования и несколько утилит поддержки написаны на языке Python. Со временем, мы надеемся перенести большую часть вспомогательного кода из C в Python, но из-за встроенных систем такие решения становятся источником постоянных противоречий и неудобств.

Тем не менее, в целом мы считаем, что влияние встроенных систем делает код достаточно прочным. Оно хорошо чувствуется в том, что пишется рациональный компактный код, который экономно использует ресурсы процессора. Говорят, что искусство создается благодаря творчеству в условиях ограничений; под таким влиянием GPSD действительно становится лучшим.

Это чувство не следует превращать непосредственно в совет для других проектов, нужно делать нечто другое: не думать, а измерять! Нет ничего другого, похожего на обычное профилирование и измерение размера занимаемой памяти, предупреждающих вас, когда вы уноситесь мыслью в создании разных наворотов, и убеждающих вас, что что-то не так.

7.8. JSON и архитектонавты

Одно из самых значительных преобразований в истории проекта произошло, когда мы перешли от первоначально используемого протокола сообщений к использованию JSON в качестве метапротокола и передачи клиентам объектов JSON. В исходном протоколе использовались однобуквенные символы в качестве команд и ответов, и т. к. возможности демона постепенно увеличивались, мы буквально задыхались в пространстве односимвольных имен.

Переход на JSON был большой победой. JSON сочетает в себе традиционные достоинства Unix использования чисто текстового формата - легко просматривать, легко редактировать с помощью стандартных инструментов, легко создавать с помощью программ - с возможностью передавать структурированную информацию более богатыми и гибкими способами.

Отобразив типы сообщений в объекты JSON, мы обеспечили, что любое сообщение может содержать объединение строковых, числовых и логические данных и структур (возможность, которой не было в старом протоколе). Благодаря тому, что типы сообщений идентифицируются с помощью атрибута class, мы обеспечили, что у нас всегда будет возможность добавлять новые типы сообщений, которые не будут мешать использовать старые.

Это решение не обошлось без затрат. Парсеру JSON требуется несколько больше вычислительных ресурсов, чем очень простому и ограниченному парсеру, который он заменил, и, конечно, в новом парсере больше строк кода (что предполагает больше мест для возникновения дефектов). Кроме того, обычные анализаторы JSON для того, чтобы справиться с массивами переменной длины и со словарями, которые описываются в JSON, требуют динамического распределения памяти, а динамическое распределение памяти является пресловутой причиной появления дефектов.

Мы справились с этими проблемами несколькими способами. Первым шагом было написание парсера на C для (достаточно) большого подмножества JSON, в котором используется исключительно статическое распределение памяти. Это потребовало принять некоторые незначительные ограничения, например, объекты в нашем диалекте JSON не могут содержать значение null, а массивы всегда имеют фиксированную максимальную длину. Приняв эти ограничения, мы смогли втиснуть парсер в 600 строк кода на языке C.

Затем мы построили полный набор юнит-тестов для парсера с тем, чтобы проверять отсутствие ошибок в операциях. Наконец, для встроенных систем с очень жесткими ограничениями, где накладные расходы для JSON могут быть слишком высокими, мы написали экспортный механизм с совместно используемой памятью, который позволяет обходиться без полного разбора и отправки сообщений JSON полностью в случае, если демон и его клиент имеет доступ к общей памяти.

JSON уже не используется только для веб-приложений. Мы считаем, что любой, кто проектирует протокол для приложения, должен рассматривать подход, похожий на используемый в проекте GPSD. Конечно, идея создания протокола поверх стандартного метапротокола не нова; поклонники XML пользуются ей в течение многих лет, и это имеет смысл для протоколов со сложной структурой документов. JSON обладает преимуществом за счет более низких накладных расходов, чем в случае с XML, и он лучше подходит при обходе массивов и структур записей.

7.9. Проектирование с минимизацией количества дефектов

Любое программное обеспечение, которое используется в навигационных системах и находится между пользователем и датчиком GPS или другим датчиком, определяющим месторасположение, потенциально является жизненно-важным, особенно в море или в воздухе. В навигационном программном обеспечении с открытым исходным кодом стараются уклониться от этой проблемы при помощи добавления соглашения об отказе от ответственности, в котором говорится: «Не полагайтесь на это, если это может поставить под угрозу жизнь».

Мы считаем, что подобные отказы бесполезны и опасны: бесполезны, поскольку системные интеграторы, весьма вероятно, относятся к ним формально и их игнорируют, и опасны, поскольку они способствуют тому, что разработчики опрометчиво считают, что дефекты в кода не будут иметь серьезных последствий, и что допустимо сглаживать острые углы в сфере обеспечения качества программ.

Разработчики проекта GPSD считают, что единственной приемлемой политикой является проект с нулевым количеством дефектов. Из-за того, что программное обеспечение остается сложным, мы этого еще не совсем достигли, но если учесть размер проекта GPSD, время работы над ним и его сложность, то мы подошли очень близко.

Нашей стратегией для этого является сочетание принципов архитектуры и политики кодирования, которые направлены на то, чтобы исключить возможность появления дефектов в поставляемом коде.

Один из важных принципов состоит в следующем: демон gpsd никогда не использует динамическое распределение памяти - нет ни malloc, ни calloc, и нет никаких обращений к функциям или библиотекам, в которых они требуются. Благодаря этому одним махом исчезает самая пресловутая причина дефектов, имеющая место при кодировании на языке C. У нас нет никаких утечек памяти и нет никаких ошибок двойных malloc или двойных free и никогда их не будет.

Нам это сходит с рук, поскольку все датчики, с которыми мы работаем, передают пакеты с относительно небольшой фиксированной максимальной длиной, а работа демона состоит в их обработке и отправке их клиентам почти без буферизации. Тем не менее, отказ от использования malloc требует дисциплины при кодировании и некоторых конструктивных компромиссов, некоторые из которых мы уже отметили при рассмотрении парсера JSON. Мы добровольно идем на эти затраты с тем, чтобы уменьшить количество дефектов.

Полезным побочным эффектом этой политики является то, что повышается эффективность чеккеров (checker) статического кода, например, splint, cppcheck и Coverity. Это ведет нас к другому крупному политическому выбору; мы интенсивно используем оба этих инструмента аудита кода, а также специально настроенный фреймворк регрессионного тестирования. Мы не знаем ни о каких других проектах, кроме GPSD, которые полностью аннотированы для использования splint, и сильно подозреваем, что ничего подобного еще не существует.

Строгая модульная архитектура GPSD помогает нам и здесь. Границы модулей служат в качестве точек отсечения, куда мы можем подсоединять специальные страховочные средства, и мы достаточно систематически это делаем. С помощью нашего обычного регрессионного тестирования проверяется все - от поведения плавающей точки на серверном оборудовании и до анализа JSON с тем, чтобы исправить сообщения о функционировании, поступающие из более чем семидесяти различных журналов датчиков.

Следует признать, что нам было несколько легче быть тщательными, чем многим другим приложениям, поскольку в демоне нет пользовательского интерфейса; окружающая его среда является простым набором последовательных потоков данных и ее относительно легко моделировать. Но, как и с отказом от использования malloc, фактическое использование этого преимущества требует правильного к нему отношения, что в частности означает, что нужно быть готовым тратить на проектирование и кодирование тестовых инструментальных средств и страховочных средств столько же времени, сколько мы тратим на создание кода. Это политика, которой, как мы думаем, могут и должны подражать другие проекты с открытым исходным кодом.

Когда я пишу эту статью (июль 2011 года), трекер ошибок проекта GPSD пуст. Он бывает пустым в течение недель, и на опыте появления сообщений об ошибках в прошлом, мы можем предполагать, что он будет оставаться таким же и далее. У нас в течение шести лет в коде не было критических ошибок. Когда у нас возникают ошибки, они, как правило, связаны к некоторым несущественными отсутствующими функциями или несоответствием спецификациеям, что легко исправляется в течение нескольких минут.

Это не означает, что проект был непрерывной идиллией. Далее мы рассмотрим некоторые из наших ошибок ...

7.10. Усвоенные уроки

Проектировать программное обеспечение трудно; в нем обычно также есть ошибки и темные закоулки, и проект GPSD не стал исключением из этого правила. Самой большой ошибкой в истории этого проекта была разработка исходного протокола, который использовался перед использованием JSON для запросов и получения информации GPS. На то, чтобы от него отказаться, потребовались годы усилий и мы усвоили уроки, связанные как ошибочным первоначальным проектированием, так и с его исправлением.

С исходным протоколом были связаны две серьезные проблемы:

  1. Плохая расширяемость. В нем использовались теги запросов и ответов, содержащие каждый одну букву без учета регистра. Так, например, запрос на получение долготы и широты был "P", а ответ выглядел, например, "P -75.32 40.05". К тому же, парсер интерпретировал запрос, например, "PA", как запрос "P" с последующим запросом "А" (altitude - высота). Поскольку возможности демона постепенно расширялись, мы буквально были вытолкнуты из пространства команд.
  2. Несоответствие между неявной моделью поведения датчиков, подразумеваемой в протоколе, и тем как датчики ведут себя на самом деле. Старый протокол состоял из запросов/ответов: посылался запрос о местоположении (или высоты, или еще чего-нибудь), который возвращал ответ через некоторое время. На самом деле, как правило, не представляется возможным запрашивать ответ от датчиков GPS или других датчиков, относящихся к навигации; они выдают поток ответов, и лучшее, что запрос может сделать, это запросить кэш. Такое несоответствие поощряло неаккуратную обработку данных в приложениях: слишком часто они запрашивают данные о местоположении, не запрашивая время или иную проверочную информацию, касающуюся качества ответа, что на практике может легко привести к тому, что пользователю будут представлены устаревшие или неправильные данные.

Еще в 2006 году стало ясно, что старый дизайн протокола был несовершенным, но для того, чтобы разработать новый протокол, потребовалось почти три года проектных эскизов и фальшстартов. После этого переход занял два года, что стало причиной головной боли у разработчиков клиентских приложений. Стоимость этого перехода могла бы быть большей, если бы проект не поставлялся в виде библиотек клиентской стороны, которые изолировали пользователей от большинства деталей протокола, но вначале у нас не было достаточно хорошего API этих библиотек.

Если бы мы знали тогда, что мы знаем теперь, то протокол на основе JSON был бы внедрен на пять лет раньше, и в проекте интерфейса API клиентских библиотек потребовалось бы делать гораздо меньше изменений. Но есть ряд уроков которые можно усвоить только благодаря практике и эксперименту.

Есть по крайней мере две рекомендации по проектированию, которые нужно иметь ввиду для того, чтобы в будущих демонах сервисов избегать повторения ошибок:

  1. Проектирование возможности расширения. Если в протоколе приложения вашего демона может не хватать пространства имен, как было в нашем старом протоколе, то вы сделали этот протокол неправильно. Переоценка краткосрочных затрат и недооценка долгосрочных преимуществ таких метапротоколов, как XML и JSON, являются ошибками, которые все еще остаются достаточно типичными.
  2. Библиотеки клиентской стороны являются лучшим подходом, нежели предоставление доступа ко всем деталям протокола приложения. Внутренняя реализация библиотеки может быть адаптирована для многих версий протокола приложения, существенно снижая как сложность интерфейса, так и показатель наличия дефектов, в сравнении с альтернативным вариантом, когда каждый автор приложения должен разрабатывать специальное решение. Это различие выливается в гораздо меньшее количество сообщений об ошибках на трекере вашего проекта.

Одним из возможных ответов на наш упор на расширяемость, причем не только в протоколе приложений GPSD, но и в других частях архитектуры проекта, например, в интерфейс драйверов пакетов, является отказ от расширяемости как от нечто лишнего, что ведет изменению назначения проекта. Программисты Unix, прошедшие школу в традиции «делать что-то одно хорошо», могут спросить, действительно ли в 2011 году нужен больший набор команд gpsd, чем было в 2006 году, и почему gpsd теперь работает с датчики, которые не являются датчиками GPS, например, с магнитными компасами и морскими приемниками Marine AIS, и почему мы обдумываем такие возможности, как слежение за воздушными судами ADS-B.

Это справедливые вопросы. Мы можем получить ответ, если взглянем на фактическую сложность добавление нового типа устройства. По очень веским причинам, в том числе из-за относительно низкого объема данных и высоких уровней электрических шумов, что исторически связанно с последовательным подключением датчиков, почти все протоколы ответов датчиков GPS и других навигационных датчиков выглядят во многом одинаково: маленькие пакеты с проверочной контрольной суммой некоторого вида. Такие протоколы неудобно обрабатывать, но их действительно нетрудно отличать друг от друга и анализировать, а дополнительные затрат на добавление нового протокола, как правило, меньше, чем делать для каждого протокола что-то отдельное. Затраты даже на самые сложные из поддерживаемых нами протоколов с добавлением собственных генераторов ответов, например, для Marine AIS, составляют порядка 3 тыс. строк на каждый протокол. Драйвера плюс анализатор пакетов и связанные с ними генераторы ответов в формате JSON составляют приблизительно в общей сложности 18 тыс. строк кода.

Если это сравнивать с 43 тыс. строк кода всего проекта в целом, то мы видим, что большая часть затрат по сложности сосредоточена в GPSD на самом деле в коде фреймворка, окружающего драйверы, и (что важно) в инструментальных средствах тестирования и во фреймворке проверки правильности демона. Их дублирование привело бы к проекту намного большего размера, чем просто создание любого отдельного парсера пакетов. Так что написание проекта, эквивалентного GPSD, для протокола пакетов, который не обрабатывается в GPSD, потребовало бы гораздо больше работы, чем добавление еще одного драйвера и набора тестов для самого GPSD. Напротив, наиболее экономичный подход (и с наименьшим накапливаемым влиянием на показатель дефектов) представляет собой увеличение в GPSD количества драйверов пакетов для большого количества различных типов датчиков.

«То одно», ради чего проект GPSD разрабатывался так, чтобы он делал это хорошо, является работа с любым набор датчиков, которые передают пакеты с различными контрольными суммами. То, что выглядит как изменение назначения, является, на самом деле, предотвращением ситуации, кв которой потребовалось бы писать много различных и дублирующих друг-друга демонов обработки. Вместо этого, разработчики приложений получили один относительно простой интерфейс API и выгоду благодаря нашей трудной экспертной победе в проектировании и тестировании большего количества типов датчиков.

То, что отличает GPSD от простой кучи возможностей нечеткого назначения, это не просто удача или черная магия, а грамотное применение известных лучших приемов разработки программного обеспечения. Отдача от них начинается с низкого уровня дефектов в настоящее время, и продолжается благодаря возможностью поддерживать новые функции без особых усилий или существенного влияния на уровень дефектности в будущем.

Возможно, самый важный урок, который мы получили для других проектов с открытым кодом, заключается в следующем: снижение дефектности асимптотически близко к нулю является сложной, но не невозможной задачей даже для такого широко внедряемого и разнообразного по назначению проекта, каким является проект GPSD. Этого можно достичь с помощью правильной архитектуры, хорошей практики кодирования, и действительными намерениями сосредоточиться на тестировании - и самым важным условием является дисциплина, которой надо следовать в этих трех направлениях.

8.1. История

История языков Iron начинается в 2003 году. Джим Хаганин (Jim Hugunin) уже написал для виртуальной машины Java (JVM) реализацию языка Python, которая называлась Jython. В тот момент среда Common Language Runtime (CLR) для .NET Framework, тогда еще новая, рассматривалась некоторыми (кем именно, точно сказать не могу) как плохо подходящая для реализации таких динамических языков, как Python. После того, как Джим реализовал язык Python на JVM, ему стало интересно, насколько фирма Microsoft сделала платформу .NET, возможно, хуже, чем язык Java. В сентябре 2006 года он писал в блоге:

Я хотел понять, насколько сильно фирма Microsoft потеряет свое лицо, т. к. среда CLR была как платформа хуже для динамических языков, чем JVM. Мой план состоял в том, чтобы потратить пару недель для сборки прототипа реализации языка Python на CLR, а затем использовать эту работу для того чтобы написать короткую содержательную статью под названием «Почему среда CLR является ужасной платформой для динамических языков». Пока я работал над прототипом, мои планы быстро изменились, поскольку обнаружил, что язык Python может работать в среде CLR - во многих случаях заметно быстрее, чем реализация на основе языка C. Для стандартного бенчмарка pystone язык IronPython в среде CLR работал приблизительно в 1,7 раза быстрее, чем реализация на основе языка C.

Часть «Iron» («железный» ), используемая в имени, стала обыгрыванием названия компании Want of a Nail Software, в которой в то время работал Джим. (Прим.пер.: Want of a Nail Software означает программы, выполняющие некоторые незначительные операции, которые, в общей совокупности, могут оказать достаточно большое воздействие, т. е. что-то вроде «эффекта бабочки»).

Вскоре после этого Джим был нанят фирмой Microsoft для того, чтобы сделать платформу .NET более подходящей для динамических языков. Джим (и несколько других разработчиков) создал среду DLR при помощи выделения языково-нейтральных частей оригинального кода реализации IronPython в отдельный код (факторинга). Для того, чтобы создать общее ядро реализации динамических языков платформы .NET, была разработана среда DLR и она было основной новой особенностью платформы .NET 4.

В тот момент, когда была анонсирована среда DLR (апрель 2007), фирма Microsoft для того, чтобы продемонстрировать приспособляемость среды DLR к различным языкам, также объявила, что в добавок к новой версии языка IronPython, собранного на базе среды DLR (IronPython 2.0), также на базе среды DLR будет разрабатываться язык IronRuby. В октябре 2010 года Microsoft прекратила разработку языков IronPython и IronRuby, и они стали независимыми проектами с открытым исходным кодом. Интеграция с динамическими языками, использующими среду DLR, также рассматривалась как основная особенность языков C# и Visual Basic, использующих новое ключевое слово (dynamic), что позволило этим языкам легко обращаться к любому языку в среде DLR или к любому другому динамическому источнику данных. Среда CLR уже была хорошей платформой для реализации статических языков, а среда DLR делает динамические языки основными игроками платформы.

К числу других реализаций языков, которыми не занималась фирма Microsoft, но в которых также используется среда DLR, относятся IronScheme и IronJS. Кроме того, в PowerShell v3 фирмы Microsoft также будет использована среда DLR вместо ее собственной системы динамических объектов.

8.2. Принципы реализации среды времени выполнения динамических языков

Среда CLR создана с учетом использования статически-типизированных языков; то, что типы данных известны, используется в среде времени выполнения очень глубоко, и одним из ключевых условий является то, что эти типы не должны изменяться, — что переменная никогда не изменит свой тип или, что в типе никогда нет будет никаких полей или членов типа, которые добавляются или удаляются во время работы программы. Это нормально для таких языков, как C# или Java, но в динамических языках эти правила, по определению, не выполняются. В среде CLR также предоставлена единая система объектов статических типов, что означает, что любой язык платформы .NET может вызывать объекты, написанные на любом другом языке платформы .NET, причем без дополнительных усилий.

Без среды DLR в каждом динамическом языке потребовалось бы создавать свою собственную модель объектов; разные динамические языки не могли бы обращаться к объектам других динамических языков, а C# не мог бы одинаковым образом обрабатывать языки IronPython и IronRuby. Поэтому сердцем среды DLR является стандартный способ реализации динамических объектов и, при этом, с помощью средств привязок (binders) все еще есть возможность настраивать свойства объектов для каждого конкретного языка. Также есть механизм, называющийся кэшированием точек вызовов (call-site caching), который обеспечивает выполнение динамических операции настолько быстро, насколько это возможно, и набор классов для создания деревьев выражений (expression trees), которые позволяют сохранять код в виде данных и легко им манипулировать.

В среде CLR также предоставляется ряд других возможностей, которые полезны для динамических языков, в том числе интеллектуальный сборщик мусора; компилятор Just-in-Time (JIT), преобразующий во время выполнения программы на байт-коде в обобщенный промежуточный язык Common Intermediate Language (IL), который компиляторы платформы .NET предобразуют в машинный код; система интроспекции времени выполнения, позволяющая динамическим языкам вызывать объекты, написанные на любом статическом языке; и, наконец, динамические методы (также известные как легковесная генерация кода), которые позволяют во время выполнения программы генерировать код, а затем исполнять его с затратами, лишь чуть-чуть превышающими затраты на статические методы вызовов (в в виртуальной машине JVM языка Java 7 аналогичный механизм реализуется с помощью >invokedynamic>).

Благодаря такой архитектуре среды DLR языки, такие как IronPython и IronRuby, могут вызвать объекты друг друга (а также любого другого языка DLR), поскольку у них общая динамическую модель объектов. Поддержка такой объектной модели была также добавлена в язык C# 4 (с помощью ключевого слова dynamic) и в Visual Basic 10 (в дополнение к методу «позднего связывания», уже существующему в VB), так что в них также можно выполнять динамические вызовы объектов. Таким образом среда DLR делает динамические языки основными игроками платформы .NET.

Интересно то, что среда DLR полностью реализована в виде набора библиотек и может также быть встроена и работать на платформе .NET 2.0. Для ее реализации в среде CLR не требуются делать никаких изменений.

8.3.Особенности реализации языков

Реализация каждого языка содержит в себе два основных этапа — синтаксический анализ или разбор (представление языка) и генерацию кода (движок). В среде DLR в каждом языке реализуется свое собственное представление языка, которое содержит синтаксический анализатор языка и генератор синтаксического дерева; среда DLR предоставляет общий движок, который использует деревья выражений для того, чтобы создать код на промежуточном языке Intermediate Language (IL) для его использования в среде CLR; среда CLR передает код на языке IL в компилятор JIT (Just-In-Time — компиляция «на лету»), с помощью которого создается машинный код, предназначенный для выполнения в процессоре. Код, который определяется во время выполнения (и работает с использованием команды eval) обрабатывается аналогичным образом, за исключением лишь того, что все это происходит исключительно в точке вызова eval, а не когда загружается файл.

Есть несколько различных способов реализации ключевых элементов представления языка, и хотя языки IronPython и IronRuby очень похожи друг на друга ( в конце концов, они разрабатывались бок о бок,) они различаются в нескольких ключевых аспектах. Оба языка IronPython и IronRuby имеют довольно стандартные структуры синтаксических анализаторов — оба они используют tokenizer (также известный как лексический анализатор), который разделяет текст на лексемы, а затем синтаксический анализатор / парсер преобразует эти лексемы в абстрактное синтаксическое дерево AST (abstract syntax tree), которое представляет собой программу. Однако реализация этих компонентов в этих языках совершенно разная.

8.4. Синтаксический анализ

Лексический анализатор языка IronPython находится в классе IronPython.Compiler.Tokenizer, а парсер — в классе IronPython.Compiler.Parser. Лексический анализатор представляет собой конечный автомат, написанный вручную, который распознает ключевые слова, операторы и имена языка Python и создает соответствующие лексемы. Каждая лексема сопровождается дополнительной информацией (например, значение константы или имени), а также для того, чтобы было проще вести отладку, указывается, где в исходном была найдена лексема. Затем парсер берет этот набор лексем и сравнивает их с грамматикой языка Python с тем, чтобы поверить, соответствует ли этот набор правильным конструкциям языка Python.

Синтаксический анализатор языка IronPython представляет собой синтаксический анализатор рекурсивного спуска вида LL(1) (LL(1) recursive descent parser). Анализатор будет анализировать входящую лексему и, если лексема допустима, вызвать функцию, либо будет возвращать ошибку, если это не так. Анализатор рекурсивного спуска собран из набора взаимно рекурсивных функций; в конечном итоге эти функции реализуют конечный автомат, в котором каждая новая лексема вызывает переход между состояниями. Точно также, как и лексический анализатор, парсер языка IronPython написан вручную.

С другой стороны в языке IronRuby используются лексический и синтаксический анализаторы, сгенерированные генератором анализаторов Gardens Point Parser Generator (GPPG). Парсер описан в файле Parser.y (Languages/Ruby/Ruby/Compiler/Parser/Parser.y), являющимся файлом в формате yacc, в котором на высоком уровне с помощью правил (rules), задающих грамматику, описана грамматика языка IronRuby. После этого генератор GPPG берет файл Parser.y и строит фактически используемые функции и таблицы парсера в результате получается парсер вида LALR(1), использующий в своей работе таблицы. Созданные таблицы представляют собой длинные массивы целых чисел, где каждое целое представляет собой состояние; в таблице по текущему состоянию и текущей лексеме определяется, какое состояние будет следующим. Анализатор рекурсивного спуска языка IronPython читается довольно легко, тогда как сгенерированный парсер языка IronRuby прочитать невозможно. Таблица переходов огромна (540 различных состояний и более 45 000 переходов), и вручную ее изменить практически невозможно.

В конечном счете, это компромисс разработки — парсер языка IronPython достаточно прост с тем, чтобы его можно было изменять вручную, но достаточно сложный, поскольку он скрывает структуру языка. Парсер языка IronRuby, с другой стороны, позволяет гораздо легче понять структуру языка, находящегося в файле Parser.y, но теперь он зависит от стороннего инструментального средства, в котором применяется специальный (хотя и известный) язык конкретной прикладной области, в котором могут быть свои собственные ошибки или особенности. В данном случае команда разработчиков языка IronPython не хотела быть зависимой от внешнего инструментального средства, тогда как команда разработчиков языка IronRuby не возражала против этого.

Однако совершенно ясно, насколько на каждом этапе для анализа важны конечные автоматы. Для любой задачи синтаксического анализа независимо от того, насколько она будет простой, конечный автомат всегда выдает правильный ответ.

Прим.пер.: В данном случае приводится описание лишь конкретных реализаций. В общем случае возможностей конченого автомата может оказаться недостаточной для реализации грамматик вида LL(1) и LALR(1). Что же касается правильности ответов, выдаваемых конечными автоматами, то они ровно настолько правильные, насколько правильно построена таблица переходов конечного автомата, причем это не зависит от того, будет ли эта таблица построена вручную в виде вызовов взаимно рекурсивных функций, либо она будет описана в виде грамматики, которая затем преобразуется в длинные массивы целых чисел.

Результатом работы синтаксического анализатора для любого языка является абстрактное синтаксическое дерево (AST). В нем на высоком уровне описывается структура программы, при этом каждый узел отображается непосредственно в конструкции языка - оператор или выражение. С этими деревьями можно манипулировать во время выполнения, часто для того, чтобы перед компиляцией оптимизировать программу. Впрочем, дерево AST некоторого языка связана с конкретным языком, а среда DLR должна работать с деревьями, в которых нет никакого конструкций конкретного языка, а содержатся только общие конструкции.

8.5. Деревья выражений

Дерево выражений (expression tree) также является представлением программы, с которым можно манипулировать во время выполнения, но в более низкогоуровневом представлении, независимым от языка. На платформе .NET, типы узлов находятся в пространстве имен System.Linq.Expressions, и все типы узлов являются производными от абстрактного класса Expression. Пространство имен является историческим артефактом; первоначально в .NET 3.5 для реализации языка интегрированных запросов LINQ - Language-Integrated Query были добавлены деревья выражений, а затем они были расширены деревьями выражений DLR. Эти деревья выражений используются не только в выражениях, хотя также есть типы узлов для инструкций if, блоков try и циклов; в некоторых языках (в Ruby, например) есть выражения и нет инструкций.

Есть узлы, которые покрывают практически все функции, которые могут потребоваться в языке программирования. Впрочем, есть тенденция определять их на достаточно низком уровне; вместо того, чтобы иметь узлы ForExpression, WhileExpression и т.д., существует один узел LoopExpression, которые в сочетании с узлом GotoExpression может описывать циклы любого типа. Для того, чтобы описывать язык на более высоком уровне, в языках можно определять свои собственные типы узлов с помощью их наследования из Expression и переопределения метода Reduce(), который возвращает другое дерево выражений. В IronPython, дерево разбора также является деревом выражений DLR, но в нем есть множество специальных узлов, которые обычно непонятны для DLR (например, ForStatement). Эти специальные узлы могут быть приведены к деревьям выражений, которые хорошо понятны в DLR (например, сочетание LoopExpressions и GotoExpressions). Эти специальные узлы выражений могут приводиться к другим специальным узлам выражений, поэтому приведение продолжается рекурсивно до тех пор, пока не останутся только узлы DLR. Одно из ключевых различий между IronPython и IronRuby состоит в том, что абстрактное синтаксическое дерево (AST) языка IronPython также является деревом выражения, а в языке IronRuby - не является. Вместо этого, прежде, чем произойдет переход на следующий этап, дерево AST языка IronRuby преобразуется в дерево выражений. На самом деле неясно, насколько полезно то, что дерево AST также может быть деревом выражений, поэтому оно не было реализовано таким образом в языке IronRuby.

Для каждого типа узла известно, как его можно сократить, причем обычно сокращение можно выполнить единственным образом. Для преобразований, которые выполняются над кодом, не находящимся в дереве — например, сворачивание констант или реализация генераторов Python в IronPython — используется подкласс класса Expression. В классе ExpressionVisitor есть метод Visit(), который вызывает метод Accept() класса Expression, а в подклассе Expression метод Accept() переопределяется таким образом, чтобы вызвать конкретный метод Visit() класса ExpressionVisitor, например, VisitBinary(). Это классическая реализация паттерна Visitor, предложенного Gamma и другими - есть фиксированный набор типов узлов, которые можно посетить, и есть бесконечное количество операций, которые над ними могут быть выполнены. Когда при разборе выражения посещается узел, то, как правило, рекурсивно посещаются его потомки, а также потомки его потомков и так далее вниз по дереву. Однако метод ExpressionVisitor не может в действительности изменить дерево при его просмотре, поскольку деревья выражений являются немутируемыми (неизменямыми). Если необходимо изменить узел (например, удалить потомков), то вместо этого следует создать новый узел, который заменит старый узел, а также всех его предков.

После того, как дерево выражений будет создано, сокращено и преобразовано к паттерну Visitor, его, в конце концов, нужно будет выполнить. Хотя деревья выражений могут быть откомпилированы непосредственно в код IL, в IronPython и IronRuby происходит их первоначальное перенаправление в интерпретатор, т. к. компиляция непосредственно в IL является слишком дорогой для кода, который, возможно, будет выполнен всего-лишь несколько раз.

8.6. Интерпретация и компиляция

Одним из недостатков использования компилятора JIT, например, применяемого на платформе .NET, является то, что ему при запуске требуется время для того, чтобы преобразовать байт-код IL в машинный код, который может исполнять процессор. При JIT-компиляции создается код, который работает намного быстрее, чем при работе через интерпретатор, но в зависимости того, что требуется сделать, затраты на запуск могут оказаться непомерно высокими. К примеру, долгоживущий серверный процесс, например, веб-приложение, выиграет от использования JIT, поскольку время запуска почти не имеет значения, а время, необходимое для на каждый запрос, является решающим, причем один и тот же код выполняется повторно. С другой стороны, для программы, которая запускается часто, но только на короткий промежуток времени, например, для клиентской программы Mercurial, работающей из командной строки, было бы лучше иметь небольшое время запуска, поскольку в ней, скорее всего, каждый фрагмент кода выполняется только один раз, а то, что код, созданный компилятором JIT, работает быстрее, не скомпенсирует того, что на запуск потребуется гораздо больше времени.

Платформа .NET не может выполнять код IL непосредственно; она всегда использует компилятор JIT для компиляции в машинный код, а на это требуется время. В частности, время запуска программ является одной из слабых сторон фреймворка .NET, поскольку компилятору JIT нужно откомпилировать большую часть кода. Хотя есть способы, которые позволяют избегать подобных затрат для статических программ платформы .NET (генерация нативных образов - Native Image Generation, или NGEN), для динамических программ они не работают. Вместо того, чтобы выполнять компиляцию всегда непосредственно в код IL, в языках IronRuby и IronPython будут использоваться их собственные интерпретаторы (находится в Microsoft.Scripting.Interpreter), которые работаю не так быстро, как код, откомпилированный с помощью JIT, но при запуске затрачивают гораздо меньше времени. Интерпретатор также полезен в ситуациях, когда не допускается динамическая генерация кода, например, на мобильных платформах; в противном случае языки DLR вообще не смогут работать.

Перед тем, как начнется выполнение, все дерево выражений должно быть преобразовано в функцию так, что его можно было выполнить. В языках DLR функции представлены в виде узлов LambdaExpression. В большинстве языков функция lambda является анонимной функцией, а в языках DLR концепция именования не используется; все функции являются анонимными. Узел LambdaExpression уникален тем, что это единственный тип узла, который может быть преобразован в делегата delegate, т. е. в такую сущность, которая на платформе .NET с использованием метода Compile()вызывает функции первого порядка. Делегат похож на указатель функций в языке C - это просто дескриптор куска кода, который можно вызывать.

Сначала дерево выражения помещается в узел LightLambdaExpression, из которого можно также создать делегата, который может быть выполнен; однако вместо того, чтобы генерировать код на языке IL (для которого затем нужно будет вызывать компилятор JIT), дерево выражений компилируется в список инструкций, которые затем выполняются на простой виртуальной машине интерпретатора. Интерпретатор является простым стековым интерпретатором; инструкции берут значения из стека, выполняют операцию, а затем помещают результат обратно в стек. Каждая команда представляет собой экземпляр класса, производного от класса Microsoft.Scripting.Interpreter.Instruction (например, AddInstruction или BranchTrueInstruction), в котором есть свойства, описывающие, сколько элементов выбирается из стека, сколько из них используется, и метод Run(), который выполняет инструкцию, выбирая значения из стека, помещая результат в стек и возвращая смещение для следующей инструкции. Интерпретатор берет список инструкций и выполняет их одну за другой, переходя вперед или назад в зависимости от значения, возвращаемого методом Run().

Как только некоторая часть кода будет выполнена определенное количество раз, она будет с помощью вызова метода LightLambdaExpression.Reduce() преобразована в полный вариант узла LambdaExpression, затем будет выполнена компиляция в делегата DynamicMethod (в фоновом потоке с целью определенного распараллеливания), и точка вызова старого делегата будут заменена новым вызовом, более быстрым. Это значительно снижает стоимость выполнения функций, вызываемых всего-лишь несколько раз, например, функция main программы, тогда как функции, к которым происходит регулярное обращение, работают настолько быстро, насколько это возможно. По умолчанию, порог компиляция установлен равным 32 кратному исполнению кода, но это значение можно изменять с помощью параметра командной строки или из хост-программы; также можно полностью отключить либо компиляцию, либо интерпретацию.

Независимо от того, работает ли интерпретатор, или происходит компиляция в код IL, компилятор дерева выражений не выполняет непосредственное преобразование в операции языка. Вместо этого компилятор создает для каждой операции, которая может быть динамической (что бывает практически всегда), точку вызова. Благодаря таким точкам вызовов можно использовать динамические объекты, а код сохранит высокую производительность.

8.7. Точки динамических вызовов

В статическом языке платформы .NET все решения о том, какой код должен быть вызван, делаются на этапе компиляции. Например, рассмотрим следующую строку кода на языке C#:

var z = x + y;

Компилятору известны типы переменных `x' и `y' и то, можно ли их складывать. Компилятор, основываясь исключительно на статической информации, в которой есть все о типах переменных, может создать правильный код, необходимый для обработки перегружаемых операторов, преобразования типов и всего того, что может понадобиться для создания кода, который будет работать должным образом. Теперь рассмотрим следующую строку кода на языке Python:

z = x + y

Компилятор языка IronPython, когда он столкнется с такой строкой, понятия не имеет, что можно делать, поскольку ему не известны типы у x и y, и даже если они ему известны, то во время выполнения возможность складывать x и y может так или иначе измениться. В принципе, это узнать можно, но ни в языке IronRuby, ни в языке IronPython нет наследования типов. Вместо того, чтобы создавать код IL для сложения чисел, IronPython создает точку вызова (call site), для которой во время выполнения будет определена вся информация.

Точка вызова является местом выполнения операции, которая будет определена на этапе исполнения программы; точки вызова реализуются как экземпляры класса System.Runtime.CompilerServices.CallSite. В динамических языках, например, в Python или Ruby, почти для каждой операции есть динамический компонент; такие динамические операции представлены в деревьях выражений как узлы DynamicExpression и компилятор дерева выражений знает, как их преобразовывать в точки вызова. Когда создается точка вызова, то еще не известно, как требуется выполнять операцию; но точка вызова будет создана с экземпляром надлежащего средства привязки к точке вызова (call site binder), специального для каждого для используемого языка и содержащего всю необходимую информацию о том, как выполнять операцию.

Рис.8.1: Диаграмма класса CallSite

В каждом языке для каждой операции будут свои собственные средства привязки к точкам вызова; причем этим средствам часто известно много различных способов выполнения операции в зависимости от того, какие в точке вызова используются аргументы. Однако, генерация таких правил дорогостоящая (в частности, компиляция их в виде делегата выполнения, в котором есть обращения к компилятору .NET JIT), поэтому в точке вызова есть многоуровневый кэш точки вызова (call site cache), где уже созданные правила сохраняются для последующего использования.

Рис.8.2: Блок-схема CallSite

Первым уровнем L0 является свойство CallSite.Target самого экземпляра точки вызова. В нем хранятся правила, которые использовались для этой точки вызова совсем недавно; для подавляющего количества точек вызова это все, что когда-либо потребуется, поскольку они всегда вызываются только с одним набором типов аргументов. Точка вызова также имеет еще один кэш L1, в котором хранятся еще 10 правил. Если свойство Target не подходит для данного вызова (например, если типы аргументов разные), то в точке вызова сначала проверяется кэш с правилами для того, чтобы выяснить, может ли надлежащий делегат создан из предыдущего вызова, и можно ли повторно использовать это правило, а не создавать новое.

Сохранение правил в кэше экономит время, поскольку время, необходимое для компиляции нового правила, значительно больше по сравнению со временем, которое нужно для проверки существующих правил. Конкретно говоря, чтобы выполнить проверку типа переменной, который является наиболее распространенным типом предиката правил, на платформе .NET затрачивается около 10 нс (проверка бинарной функция занимает 20 нсек, и т.д.). С другой стороны компиляция простого метода, который складывает пару чисел, занимает приблизительно 80 мксек или на три порядка больше. Размер кэша ограничивается с тем, чтобы предотвратить излишнюю трату памяти на запоминание каждого правила, которое применяется в точке вызова; для простого сложения на каждый вариант требуется около 1 Кб памяти. Однако, профилирование показало, что для очень небольшого количества точек вызова когда-либо используется более 10 вариантов.

Наконец, есть кэш L2, в котором хранится экземпляр самого средства привязки. Экземпляр привязки, который ассоциирован с точкой вызова, может хранить некоторую дополнительную информацию о том, что делает эта конкретная точка вызова, но, в любом случае, большая часть точек вызовов не будут уникальными и для них можно совместно использовать один и тот же экземпляр средства привязки. Например, в языке Python, базовые правила, используемые для сложения, будут во всей программе одинаковыми; они зависят от двух типов данных, находящихся по обе стороны операции + - и все. Все операции сложения в программе могут пользоваться одним и тем же средством привязки, и, если не удается воспользоваться обоими кэшами L0 и L1, то в кэше L2 можно обнаружить гораздо больше правил (128), которые использовались последними во всей программе. Даже если это первое выполнение точки вызова, есть достаточно большой шанс найти соответствующее правило в кэше L2. Для того, чтобы этот метод работал наиболее эффективно, и в IronPython и в IronRuby предлагается набор канонических экземпляров средств привязки, которые применяются для обычных операций, таких как сложение.

Если кэш L2 использован не будет, то средство привязки запросит создать реализацию (implementation) для точки вызова, в которой учтены типы аргументов (и, возможно, даже их значения). Если в приведенном выше примере x и y имеют тип double (или другой нативный тип), то реализацией будет просто приведение их к типу double и вызов инструкции сложения add на языке IL. Средство привязки также создает тест, который проверяет аргументы и гарантирует, что они допустимы для этой реализации. Реализация и тест вместе образуют правило. В большинстве случаев, как реализация, так и тест создаются и хранятся в виде деревьев выражений. Однако инфраструктура точек вызовов не зависит от деревьев выражений; ее можно использовать отдельно с делегатами.

Если деревья выражений записать на языке C#, то код будет похож на следующий:

if(x is double && y is double) {       // проверятся, является ли тип переменных типом double
      return (double)x + (double)y;    // если тип double, то происходит выполнение
 }
 return site.Update(site, x, y);       // если тип не double, то ищется/создается еще одно 
                                       // правило для типов этих переменных

Затем средство привязки создает из деревьев выражений делегата (delegate), что означает, что правило компилируется в язык IL, а затем - в машинный код. В случае сложения двух чисел, это, вероятно, будет быстрая проверка типов, а затем - машинная команда сложения чисел. Даже с учетом всех используемых приемов, конечный результат будет лишь чуть-чуть медленнее, чем статический код. В языках IronPython и IronRuby также есть набор предварительно скомпилированных правил для обычных операций, таких как сложение примитивных типов, что позволяет экономить время, поскольку их не требуется создавать во время выполнения, но за это приходится заплатить некоторым дополнительным пространством на диске.

8.8. Протокол метаобъектов

Кроме того, инфраструктура языков, которая является другой ключевой частью среды DLR, дает возможность языку (основному языку или хост-языку) осуществлять динамические вызовы объектов, определенных на другом языке (языке исходного кода объекта). Чтобы это было возможно, среда DLR должна понимать, какие операции являются допустимыми над на объектом, независимо от того, на каком языке он был написан. В языках Python и Ruby есть довольно похожие модели объектов, но язык JavaScript имеет радикально другую систему типов, базирующихся на прототипах (в отличие от системы, базирующих на классах). Вместо того, чтобы пытаться объединять различные системы типов, среда DLR обрабатывает их так, как если бы все они базировались на принципах передачи сообщений (message passing) в стиле языка Smalltalk.

В объектно-ориентированной системе, базирующейся на передаче сообщений, объекты посылают сообщения другим объектам (как правило, с параметрами), а затем объект может в качестве результата возвращать другой объект. Таким образом, хотя в каждом языке есть свое собственное понимание о том, что может представлять собой объект, они все почти всегда рассматриваться как эквивалентные за счет вызовов методов просмотра, которые передаются в виде сообщений между объектами. Конечно, даже статические объектно-ориентированные языки подходят к этой модели с некоторой натяжкой; отличие динамических языков состоит в том, что вызываемый метод не должны быть известен во время компиляции, или может даже вообще не существовать в объекте (например, метод method_missing языка Ruby), а у целевого объекта обычно есть возможность, если это потребуется, перехватить сообщение и обработать его по-своему (например, метод __getattr__ языка Python).

В среде DLR определяется следующие сообщения:

Этих операций, если взять их вместе, должно быть достаточно для реализации почти любой объектной модели языка.

Поскольку среда CLR наследует статическую типизацию, объекты динамических языков все еще необходимо выражать с помощью статических классов. Обычно это происходит с помощью статического класса, например, PythonObject, а реальные объекты языка Python являются экземплярами этого класса или его подклассов. Чтобы обеспечить возможность совместного использования и хорошую производительность, в среде DLR применяется гораздо более сложный механизм. Вместо того, чтобы иметь дело с объектами конкретных языков, в среде DLR используются мета-объекты (meta-objects), которые являются подклассами класса System.Dynamic.DynamicMetaObject и имеют методы для обработки всех перечисленных выше видов сообщений. В каждом языке есть свой собственный подкласс DynamicMetaObject, реализующий модель объектов языка, например, класс MetaPythonObject в языке IronPython. В метаклассах также есть соответствующие конкретные классы, реализующие интерфейс System.Dynamic.IDynamicMetaObjectProtocol, в котором определено, как в среде DLR идентифицируются динамические объекты.

Рис.8.3: Диаграмма класса IDMOP

Из класса, в котором реализован протокол IDynamicMetaObjectProtocol, среда DLR с помощью вызова метода GetMetaObject() может получить объект DynamicMetaObject. Этот объект DynamicMetaObject будет предоставлен языком и будет реализован с помощью функций привязок, которые требуются самому объекту. У каждого объекта DynamicMetaObject также есть значение и тип, если таковые есть в объекте, лежащем в его основе. Наконец, в объекте DynamicMetaObject есть дерево выражений, в котором все еще хранятся точки вызовов и указаны ограничения на каждое выражение, аналогичные механизмам привязок точек вызова.

Когда среда DLR компилирует обращение к методу класса, определяемого пользователем, то сначала создается точка вызова (т.е. экземпляр класса CallSite). В точке вызова инициируется процесс привязки так, как он описан выше в разделе «Точки динамических вызовов», в результате чего он в конечном итоге происходит вызов метода GetMetaObject() в экземпляре класса OldInstance, который возвращает объект MetaOldInstance. В языке Python есть классы как старого, так и нового вида, но в данном случае это неважно. Затем вызывается средство привязки (а именно PythonGetMemberBinder.Bind()), вызывающее, в свою очередь, метод MetaOldInstance.BindGetMember(); он возвращает новый объект DynamicMetaObject, который представляет собой новый механизм поиска имени метода объекта. После этого вызывается другое средство привязки PythonInvokeBinder.Bind(), в котором вызывается метод MetaOldInstance.BindInvoke(), представляющий собой обвертку первого объекта DynamicMetaObject с новым способом вызова искомого метода. Здесь присутствует исходный объект, дерево выражений, используемого для поиска имени метода, и объекты DynamicMetaObject, представляющие собой аргументы, используемые в методе.

Как только в выражении будет построен объект DynamicMetaObject, его дерево выражений и ограничения будут использованы для создания делегата, затем возвращаемого в точку вызова, которая инициировала процесс привязки. С этого момента код можно сохранять в кэшах точки вызова, что позволяет выполнять операции над объектами также же быстро, как выполняются другие динамические вызовы, и почти также быстро, как выполняются статические вызовы.

Хост-языки, в которых требуется выполнять динамические операции для динамических языков, должны создавать соответствующие привязки из метапривязки DynamicMetaObjectBinder. DynamicMetaObjectBinder прежде, чем возвращаться к семантике привязок хост-языка, сначала запросит целевой объект, к которому привязывается операция (с помощью вызова метода GetMetaObject() и выполнения процесса связывания, описанного выше). В результате, если объект языка IronRuby будет доступен из программы IronPython, то привязка сначала будет происходить в семантике языка Ruby (целевой язык); а если этого сделать не удасться, то привязка DynamicMetaObjectBinder вернется к семантике языка Python (хост-язык). Если объект, для которого делается привязка, не динамический (то есть, в нем не реализован провайдер IDynamicMetaObjectProvider), как, например, классы из базовой библиотеки платформы .NET, то для доступа к нему с семантикой хост-языка используется механизм .NET reflection.

В языкам предоставлена относительная свобода реализации этого механизма; реализация PythonInvokeBinder в языке IronPython не выводится из InvokeBinder, т.к. для объектов языка Python нужно выполнять некоторую специальную дополнительную обработку. Пока это касается только объектов языка Python, то никаких проблем не возникает; если встречается объект, в котором реализован провайдер IDynamicMetaObjectProvider, и он не является объектом языка Python, то такой объект перенаправляется в класс CompatibilityInvokeBinder, который наследуется от класса InvokeBinder и который может правильно обрабатывать чужие объекты.

Если при возвращении к семантике хост-языка выполнить привязку операцию не удается, исключительное состояние не создается; вместо этого возвращается объект DynamicMetaObject, представляющий собой ошибку. Затем средство привязки хост-языков обработает его так, как это делается в самом хост-языке; например, доступ к отсутствующему члену объекта языка IronPython из гипотетической реализации JavaScript может вернуть неопределенное значение undefined, тогда как то же самое действие в объекте языка JavaScript из IronPython будет причиной возникновения ошибки AttributeError.

Возможность языков работать с динамическими объектами практически бесполезна без возможности языков загружать и выполнять код, написанный на других языках, а для этого в среде DLR для других языков предоставляется единый механизм.

8.9. Хост-среда

Кроме обеспечения общих особенностей реализации языков, в среде DLR также предоставлен елиный используемый хост-интерфейс (hosting interface). Хост-интерфейс используется хост-языком (обычно статическим языком, например, языком C#) для выполнения кода, написанного на другом языке, например, на языке Python или Ruby. Это обычный метод, который позволяет конечным пользователям расширять приложение, а среда DLR двигается на шаг вперед, делая тривиальным использование любого скриптового языка, реализация которого есть в среде DLR. В хост-инстрефейсе присутсвуют следующие четыре ключевые компонента: среда времени выполнения runtime, движки engines, средства работы с исходным кодом sources и механизмы scopes, реализующие среду выполнения кода.

Среда времени выполнения ScriptRuntime, как правило, используется совместно всеми динамическими языками, имеющимися в приложении. Среда времени выполнения обрабатывает все текущие ссылки, которые присутствуют на загруженный языках, предоставляет методы быстрого выполнения файлов, а также предоставляет методы создания новых движков. Для простых скриптовых задач, среда времени выполнения является лишь интерфейсом, которым необходимо пользоваться, но в DLR также предлагаются классы, обеспечивающие лучший контроль над тем, как выполняются скрипты.

Как правило, для каждого скриптового языка используется только один движок ScriptEngine. Поскольку используется протокол мета-объектов среды DLR, то это значит, что программа может загружать скрипты, написанные на различных языках, а объекты, созданные на каждом отдельном языке, могут легко взаимодействовать между собой. Движок представляет собой обвертку вокруг контекста LanguageContext, специфического для каждого конкретного языка (например, PythonContext или RubyContext), и используется для выполнения кода, загружаемого из файла или строк, а также для выполнения операций над динамическими объектами для языков, в которых изначально среда DLR не поддерживается (например, язык C# до версии .NET 4). Движки являются поточно-ориентированными и поскольку каждый поток работает в своей собственной области видимости, параллельно могут выполнять несколько скриптов. Также предоставляются методы, применяемые при написании исходных кодов скриптов и обеспечивающие более детальный контроль над выполнением скрипта.

Фрагменты кода, который должен выполняться, содержатся внутри класса ScriptSource; этот компонент привязывается объект SourceUnit, в котором хранится фактический код, к движку ScriptEngine, для которого создан исходный код. Этот класс позволяет компилировать этот код (в результате создается объект CompiledCode, который можно кэшировать) и непосредственно его выполнять. Если фрагмент кода будет выполняться неоднократно, то его лучше сначала скомпилировать, а затем выполнять скомпилированный код; для скриптов, которые выполняются однократно, код лучше просто непосредственно выполнить.

Когда, в конце концов, потребуется выполнить код, то с помощью механизма ScriptScope нужно будет создать среду, в которой он будет выполняться. Эта среда используется для хранения всех переменных, используемых в скрипте, и в ней может выполнять, если это необходимо, предварительную загрузку переменных из хост-среды. Это позволяет хост-среде передавать пользовательские объекты в скрипт, когда тот начинает работать; например, когда скрипт работает, то можно с помощью редактора изображений предоставить метод для доступа к пикселям изображения. После того, как скрипт закончит работать, из среды работы скрипта можно будет прочитать переменные, которые он создал. Другой основной особенностью среды выполнения является то, что она изолирует работу скрипта, поэтому одновременно можно загружать и выполнять несколько скриптов, которые не будут мешать друг другу.

Важно отметить, что все эти классы предоставляются средой DLR, а не языком; из реализации языка в движке используется только класс контекста языка LanguageContext. В контексте языка предоставляются все функциональные возможности: загрузка кода, создание среды выполнения, компиляция, выполнение кода и выполнение операций над динамическими объектами — то, что необходимо хост-среде, а в среде DLR предоставлются классы, обеспечивающий более удобный интерфейс доступа к этим функциональным возможностям. Благодаря этому один и тот же код хост-среды можно использовать для любого языка, разрабатываемого в среде DLR.

Чтобы реализовать динамический язык, написанный на языке C (например, оригинальные языки Python и Ruby), в динамическом языке необходимо написать специальный код-обвертку, причем это требуется повторять для каждого поддерживаемого скриптового языка. Хотя есть программы, например, SWIG, которые упрощают эту работу, добавить в программу скриптовый интерфейса языка Python или Ruby и предоставить внешним скриптам возможность работать с объектной моделью все еще продолжает оставаться нетривиальной задачей. Однако в среде .NET добавление возможности использования скриптов выполняется достаточно просто с помощью помощи настройки среды выполнения, загрузки в среду выполнения частей программы и применение метода ScriptScope.SetVariable() для того, чтобы объекты программы стали доступными для скриптов. Можно в течение нескольких минут добавить в приложение среды .NET поддержку выполнения скриптов, что является огромным преимуществом среды DLR.

8.10. Общая структура

Поскольку среда DLR эволюционировала из отдельной библиотеки в часть среды CLR, есть компоненты, которые, которые находятся в среде CLR (точки вызовов, деревья выражений, средства привязки, генерация кода и динамические метаобъекты), и компоненты, которые являются частью проекта языков IronLanguages, имеющего открытый исходный код (хостинг, интерпретатор и несколько других небольших компонентов, которые здесь не рассматриваются). Компоненты, находящиеся в среде CLR, также включены в состав проекта IronLanguages в виде компонента Microsoft.Scripting.Core. Компоненты среды DLR разделены на две составляющие - Microsoft.Scripting и Microsoft.Dynamic — к первой относятся интерфейсы API хостинга, а во второй находится код взаимодействия с объектами COM, интерпретатор и некоторые другие общие компоненты динамических языков.

Сами языки также разделены на две составляющие: в IronPython.dll и IronRuby.dll реализованы сами языки (синтаксические анализаторы, средства привязки и т.д.), а в IronPython.Modules.dll и IronRuby.Libraries.dll реализованы те части стандартной библиотеки, которые в классических реализациях языков Python и Ruby написаны на языке C.

8.11. Усвоенные уроки

Среда DLR является полезным примером языково-нейтральной платформы для динамических языков, создаваемых поверх статический среды времени выполнения. Технологии, требующиеся для достижение высокой производительности динамического кода, достаточно нетривиальны при их надлежащей реализации, поэтому они были реализованы в среде DLR и были сделаны доступными для каждой реализации динамического языка.

IronPython и IronRuby являются хорошими примерами того, как создавать язык поверх среды DLR. Реализации очень похожи друг на друга, т.к. они разрабатывались в одно и то же время тесно сотрудничающими командами, но в реализации есть существенные различия. Совместная разработка нескольких разных языков (IronPython, IronRuby, прототип JavaScript и таинственный VBx - полностью динамическая версия VB), а также динамических возможностей языков C# и VB позволили во время разработки достаточно хорошо протестировать проект DLR.

Реальная разработка IronPython, IronRuby и DLR выполнялась совсем не так, как это было в то время в большинстве проектов Microsoft - с первого дня использовалась интерактивная модель agile-разработки с непрерывной интеграцией. Это позволило вносить измениться очень быстро, сразу, как это становилось необходимым, что было очень хорошо, поскольку уже на ранних стадиях разработки среда DLR стала разрабатываться в связке с динамическими возможностями языка С#. Хотя тесты среды DLR проходили очень быстро, всего лишь десятки секунд или около того, запуск тестов языка занимал слишком много времени (комплект тестов IronPython выполнялся в течение приблизительно 45 минут даже при использовании распараллеливания); улучшение этой ситуации смогло бы повысить скорость итераций. В конечном счете, эти итерации сходились к текущей разработке DLR, что, если это рассматривать по отдельности, выглядит чрезвычайно сложно, но когда это собиралось вместе, то, в общем, выглядело достаточно хорошо.

Разработка среды DLR в связке с языком C# была исключительно важным решением, поскольку это убедило, что у DLR есть сфера применения и «целевое назначение», но когда динамические возможности языка C# были реализованы, изменился политический климат (совпадающий с экономическим спадом) и с стороны компании была прекращена поддержка языков Iron. Например, интерфейс API хостинга никогда не входил в состав фреймворка .NET (и маловероятно, что это когда-нибудь произойдет); что означает, что, хотя все объекты все еще могут взаимодействовать так, как описано выше, в PowerShell 3, который также основан на среде DLR, используется набор интерфейсов API хостинга, абсолютно отличающийся от используемых в IronPython и IronRuby. Некоторые из членов команды, разрабатывающей DLR, продолжали работать над библиотекой «компилятор как сервис» языка C#, имеющей кодовое название «Roslyn» и поразительно похожей на интерфейс API хостинга IronPython и IronRuby. Но, благодаря чуду лицензирования открытого исходного кода, языки Iron продолжают выживать и даже процветают.

9.1. Что такое ITK?

ITK, the Insight Toolkit является библиотекой для анализа изображений, которая была разработана по инициативе и при практически полной финансовой поддержке Национальной библиотеки медицины США. ITK может рассматриваться в качестве полезной энциклопедии алгоритмов для анализа изображений, в частности алгоритмов обработки изображений с помощью фильтров, сегментации и геометрической коррекции изображений. Библиотека была разработана консорциумом, включающим университеты, коммерческие компании и индивидуальных разработчиков со всего мира. Разработка ITK началась в 1999 году и после недавнего десятилетия код библиотеки был подвергнут рефакторингу, направленному на удаление устаревшего кода и внесение улучшений, позволяющих использовать библиотеку в течение следующих десяти лет.

9.2. Возможности архитектуры

Программные тулкиты обычно очень тесно связаны со своими сообществами разработчиков. Они подстраиваются друг под друга в продолжающемся последовательном цикле. Программное обеспечение постоянно модифицируется до того момента, пока оно начинает удовлетворять требованиям сообщества, при этом сообщество само адаптируется к возможностям тулкита, устанавливая действия, которые программный компонент позволяет или запрещает выполнять. Поэтому для лучшего понимания особенностей архитектуры ITK очень полезно иметь представление о том, с какими проблемами постоянно сталкивалось сообщество разработчиков и как эти проблемы обычно решались.

Характер зверя

Если вы не понимаете характер зверей, знания в области их анатомии вам не очень помогут.
- Dee Hock, One from Many: Visa and the Rise of Chaordic Organization

При рассмотрении типичной задачи по анализу изображений исследователь или инженер возьмет исходное изображение, улучшит некоторые характеристики этого изображения, скажем, убрав шумы и повысив контрастность, после чего перейдет к установлению некоторых параметров изображения, таких, как углы и острые края. Этот тип обработки изначально хорошо совместим с архитектурой конвейера данных, как показано на Рисунке 9.1.

Конвейер обработки изображения
Рисунок 9.1: Конвейер обработки изображения

Для иллюстрации этого утверждения на Рисунке 9.2 показан снимок головного мозга, полученный в ходе магнитно-томографического резонансного исследования (magnetic resonance image (MRI)) и результат его обработки с помощью медианного фильтра для снижения уровня шума, а также результат применения фильтра выделения контуров для идентификации границ анатомических структур.

Снимок головного мозга в ходе МРТ Результат использования медианного фильтра Результат использования фильтра выделения контуров
Рисунок 9.2: Снимок головного мозга в ходе МРТ, результат использования медианного фильтра, результат использования фильтра выделения контуров

Для решения каждой из этих задач сообщество разработчиков алгоритмов анализа изображений реализовало множество алгоритмов и продолжает вести работу над новыми. Вы можете спросить: "Почему они продолжают делать это?" и ответ на ваш вопрос будет заключаться в том, что в процессе обработки изображений используется комбинация научных знаний, инженерных расчетов, искусства и навыков "подготовки" материала. Утверждение о том, что существует комбинация алгоритмов, которая позволяет "корректно" решить задачу анализа изображения настолько же ошибочно, насколько и утверждение о том, что существует "правильный" тип шоколадного десерта для обеда. Вместо доведения алгоритмов до совершенства сообщество разработчиков пытается создать набор разнообразных инструментов, в случае использования которого вы можете быть уверены в том, что при решении задачи по анализу изображения, набор параметров анализа не окажется недостаточным. Конечно же, такие возможности имеют свою цену. Ценой в данном случае являются сложности выбора из множества различных инструментов тех, которые необходимы, причем инструменты могут использоваться совместно в различных комбинациях для получения похожих результатов.

Сообщество разработчиков алгоритмов анализа изображений тесно связано с сообществом исследователей. Нередко оказывается, что определенные группы исследователей связаны с семействами алгоритмов, которые они разработали. Эта традиция "брендинга" и в некоторой степени "маркетинга" приводит к ситуации, в которой лучшим вариантом, который программный тулкит может предложить сообществу, является предложение полного набора реализаций алгоритмов, которые оно может попробовать, а также подобрать для создания рецепта, удовлетворяющего его потребностям.

Существует несколько причин, по которым тулкит ITK был спроектирован и реализован в виде обширной коллекции частично независимых, но связанных инструментов, являющихся фильтрами изображений (image filters), многие из которых могут использоваться для решения аналогичных задач. В этом контексте присутствует некоторый уровень "избыточности" - например, предоставление трех различных реализаций фильтра Гаусса рассматривается не как проблема, а как полезная возможность, так как различные реализации могут быть взаимозаменяемыми для работы в различных условиях и повышения эффективности в зависимости от размера изображения, количества процессоров и размера ядра метода Гаусса, который может быть задан используемым приложением обработки изображений.

Также тулкит создавался в виде ресурса, который должен развиваться и постоянно обновлять самого себя по мере появления новых и улучшения реализаций существующих алгоритмов, замены существующих алгоритмов, а также разработки новых инструментов для удовлетворения срочных требований, предъявляемых новыми технологиями создания медицинских изображений.

Вооружившись знаниями, полученными в ходе краткого обзора ежедневных дел специалиста по анализу изображений из сообщества разработчиков ITK, мы можем перейти к более подробному рассмотрению наиболее важных особенностей архитектуры:

Модульность

Модульность является одной из основных характеристик ITK. Это требование, которое вытекает из метода работы членов сообщества специалистов по анализу изображений при решении поставленных перед ними задач. Большинство задач по анализу изображений подразумевает обработку одного или нескольких изображений с помощью комбинации фильтров для улучшения качества или выделения определенной информации из изображения. Следовательно, не существует одного большого объекта для обработки изображений, а вместо него используется несметное количество малых объектов. Из этого структурного характера задачи обработки изображений логически вытекает необходимость реализации программного обеспечения в виде большой коллекции фильтров для обработки изображений, которые могут комбинироваться различными способами.

Также в данном случае некоторые фильтры для обработки изображений объединяются в семейства, внутри которых некоторые из особенностей их реализации могут отличаться. Это позволяет произвести изначальное разделение фильтров для обработки изображений на модули и группы модулей.

Следовательно, модульность реализуется на трех основных уровнях в рамках ITK:

На уровне фильтра обработки изображений ITK содержит около 700 фильтров. Учитывая то, что фреймворк ITK реализован с использованием языка программирования C++, это уровень, на котором каждый из этих фильтров реализован с помощью класса C++ в соответствии с шаблонами объектно-ориентированного проектирования. На уровне семейства фильтров ITK группирует фильтры в соответствии с характером обработки изображения, которую они выполняют. Например, все фильтры, выполняющие преобразования Фурье, будут объединены в рамках модуля. На уровне языка C++ модули ставятся в соответствие директориям в дереве исходного кода и библиотекам после компиляции исходного кода в бинарный формат. ITK содержит около 120 таких модулей. Каждый модуль содержит:

  1. Исходный код фильтров изображений, принадлежащих рассматриваемому семейству.
  2. Набор файлов конфигурации, которые описывают метод сборки модуля и содержат список зависимостей между рассматриваемым и сторонними модулями.
  3. Набор модульных тестов, соответствующих каждому из фильтров.

Иерархическая структура групп, модулей и классов
Рисунок 9.3: Иерархическая структура групп, модулей и классов

На уровне групп производится в большей степени концептуальное деление, не зависящее от характеристик программного обеспечения и упрощающее поиск фильтров в дереве исходного кода. Группы ассоциированы с такими высокоуровневыми концепциями, как фильтрация (Filtering), сегментация (Segmentation), геометрическая коррекция (Registration) и ввод/вывод (IO). Иерархическая структура показана на Рисунке 9.3. В данный момент в комплекте поставки ITK насчитывается 124 модуля, которые в свою очередь распределены между 13 основными группами. Модули значительно различаются в размерах. Распределение размеров модулей в байтах отображено на Рисунке 9.4.

Распределение размеров 50 наиболее больших модулей ITK в КБ
Рисунок 9.4: Распределение размеров 50 наиболее больших модулей ITK в КБ

Применяющийся в ITK модульный подход также справедлив и в случае сторонних библиотек, которые не являются частью тулкита, но от которых зависит его работа и которые распространяются в комплекте поставки тулкита вместе с остальным кодом для удобства пользователей. Отдельными примерами этих сторонних библиотек являются библиотеки для работы с файлами изображений различных форматов: HDF5, PNG, TIFF, JPEG и OpenJPEG, а также другие. Вопрос о сторонних библиотеках поднимается по той причине, что на их счет приходится около 56 процентов размера комплекта поставки ITK. Это обстоятельство отражает обычной подход, применяемый при сборке приложений с открытым исходным кодом для существующих платформ. Распределение размеров сторонних библиотек не обязательно соответствует архитектурным особенностям ITK, так как мы используем эти полезные библиотеки в том виде, в каком они были разработаны. Однако, сторонний код перераспределяется вместе с кодом тулкита и его отделение являлось одной из ключевых директив процесса формирования модулей.

Распределение размеров модулей было приведено выше, так как оно является мерой качества разделения кода на модули. Можно заметить, что модульность кода представлена в виде непрерывного диапазона от экстремума, соответствующего расположению всего кода в одном модуле в виде монолитной версии до экстремума разделения всего кода на очень большой набор модулей одинакового размера. Распределение размеров было инструментом, используемым для отслеживания степени выполнения процесса разделения кода на модули, особенно для уверенности в том, что не было оставлено больших блоков кода в одном и том же модуле в случае, когда не имеется реальных логических зависимостей для такой группировки.

Модульная архитектура ITK позволяет выполнить и упрощает:

Процесс разделения кода на модули позволил четко идентифицировать и описать зависимости между различными частями тулкита, так как они были перенесены в модули. Во многих случаях эта деятельность позволила выявить искусственные или некорректные зависимости, которые были заданы в рамках тулкита в течение длительного промежутка времени и не были замечены тогда, когда большая часть кода находилась в файлах из нескольких групп большого размера.

Польза от получения качественных параметров двояка. Во-первых, появляется возможность сделать разработчиков ответственными за модули, развитие которых они поддерживают. Во-вторых, появляется возможность занятия инициативами по очистке кода, в ходе которых несколько разработчиков в течение короткого времени работают над повышением качества определенного модуля. При работе с небольшими частями тулкита проще оценивать эффективность своей работы и поддерживать интерес и мотивацию разработчиков.

В качестве повторения отменим, что структура тулкита отражает организацию сообщества разработчиков, а также в некоторых случаях процессы, которые были введены для постоянного развития и контроля качества программного обеспечения.

Конвейер данных

Ступенчатый характер задач анализа изображений изначально обуславливает выбор архитектуры конвейера данных для основной инфраструктуры обработки данных. Конвейер данных позволяет выполнять следующие действия:

На Рисунках 9.1 и 9.2 уже было продемонстрировано упрощенное представление конвейера данных с точки зрения обработки изображений. Фильтры изображений обычно имеют числовые параметры, которые используются для управления поведением фильтра. В любой момент, когда значение одного из числовых параметров фильтра изменяется, конвейер данных устанавливает отметку "устаревшее" для результирующего изображения и располагает информацией о том, что этот определенный фильтр и все фильтры после него, использовавшие его вывод, должны быть повторно применены к изображению. Эта возможность конвейера данных упрощает исследование эффекта от изменения параметров фильтров при использовании минимального объема вычислительных мощностей для проведения каждого эксперимента.

Процесс обновления данных конвейера может быть реализован таким образом, что в каждый момент времени будут обрабатываться только части изображений. Это необходимый механизм для поддержки функции потоковой передачи данных. На практике этот процесс контролируется путем внутренней передачи спецификации RequestRegion от следующего в цепочке фильтра к используемому. Это взаимодействие осуществляется с помощью внутреннего API и не раскрывается для разработчиков приложений.

Для рассмотрения более конкретного примера, предположим, что если фильтр размытия изображения Гаусса ожидает изображение разрешением 100x100 пикселей на входе, которое должно быть сформировано медианным фильтром изображения, то фильтр размытия может запросить у медианного фильтра создание только четверти изображения, которая будет представлена фрагментом размером 100x25 пикселей. Этот запрос впоследствии может быть передан дальше по цепочке с учетом того, что каждый промежуточный фильтр может добавить дополнительную границу к размеру фрагмента изображения для предоставления запрошенного выходного размера этого фрагмента. Потоковая передача данных будет рассмотрена более подробно ниже.

И изменение параметров заданного фильтра, и изменение определенного фрагмента изображения путем обработки с помощью фильтра приведут к установлению отметки "устаревшее" на результирующее изображение, указывающей на необходимость повторного использования заданного и всех следующих за ним в цепочке фильтров из состава конвейера данных.

Объекты процесса и данных

Для сохранения базовой структуры конвейера были спроектированы два основных типа объектов. Это DataObject и ProcessObject. Объект DataObject является абстракцией над классами для хранения данных; например, изображений и геометрических сеток. Объект ProcessObject является абстракцией над фильтрами изображений и фильтрами сеток, с помощью которых обрабатываются данные. Объекты ProcessObject принимают объекты DataObject в качестве исходных данных и проводят с ними какие-либо алгоритмические трансформации, подобные проиллюстрированным на Рисунке 9.2.

Объекты DataObject генерируются объектами ProcessObject. Эта цепочка обычно начинается с чтения данных для объекта DataObject с диска, например, с помощью объекта ImageFileReader, являющегося одним из типов объекта ProcessObject. Объект ProcessObject, создавший заданный объект DataObject, является единственным объектом, который может модифицировать данный объект DataObject. Этот результирующий объект DataObject объединен с входом другого объекта ProcessObject, находящегося далее в цепочке.

Взаимодействия между объектами ProcessObject и DataObject
Рисунок 9.5: Взаимодействия между объектами ProcessObject и DataObject

Эта последовательность проиллюстрирована на Рисунке 9.5. Один и тот же объект DataObject может быть передан на вход множества объектов ProcessObject, как показано на рисунке, где объект DataObject создается путем чтения из файла в начале конвейера. В данном случае объект для чтения из файла является экземпляром класса ImageFileReader, а объект DataObject, создаваемый в результате чтения - экземпляром класса Image. Для некоторых фильтров также свойственно требовать по два объекта DataObject в качестве входных данных, как в случае фильтра вычитания, показанного в правой части того же рисунка.

Объединение объектов ProcessObject и DataObject является побочным эффектом процесса формирования конвейера. С точки зрения разработчика приложений элементы конвейера связаны друг с другом путем использования последовательности вызовов, затрагивающих объекты ProcessObject и аналогичных следующим:

writer->SetInput ( canny->GetOutput() );
canny->SetInput ( median->GetOutput() );
median->SetInput ( reader->GetOutput() );

Однако, во внутреннем представлении результатом этих вызовов является не соединение объекта ProcessObject со следующим объектом ProcessObject, а объединение предыдущего объекта ProcessObject с объектом DataObject, созданным следующим объектом ProcessObject.

Внутренняя структура цепочки конвейера формируется с помощью трех типов объединений:

Этот набор внутренних связей используется позднее для выполнения запросов к последующим и предыдущим фильтрам в рамках конвейера. В ходе всех этих взаимодействий объект ProcessObject удерживает контроль и распоряжается объектом DataObject, который он создал. Последующие фильтры получают доступ к информации о заданном объекте DataObject с помощью ссылок в форме указателей, которые создаются в результате вызовов методов SetInput() и GetOutput() без получения контроля над входными данными. В практических целях фильтры должны рассматривать входные данные как объекты, предназначенные только для чтения. Такое поведение устанавливается на уровне API с помощью ключевого слова const языка C++ для аргументов методов SetInput(). Как правило, в рамках ITK создается корректный в отношении констант внешний API, даже с учетом того, что во внутреннем представлении константы иногда переназначаются некоторыми операциями конвейера.

Иерархия классов конвейера

Иерархия классов ProcessObject и DataObject
Рисунок 9.6: Иерархия классов ProcessObject и DataObject

Начальное проектирование и реализация конвейера данных фреймворка ITK происходили по аналогии с Visualization Toolkit (VTK), развитым проектом на момент начала разработки ITK. (Обратитесь к тому 1 книги "Архитектура приложений с открытым исходным кодом").

На Рисунке 9.6 показана иерархия объектов конвейера ITK. В частности следует отметить взаимосвязь между основными объектами Object, ProcessObject, DataObject, некоторыми классами из семейства фильтров и семейства хранилищ данных. В рамках данной абстракции любой объект, который предназначен для передачи фильтру или создается в ходе вывода данных фильтром, должен наследоваться от класса DataObject. Все фильтры, которые выводят и принимают данные должны наследоваться от класса ProcessObject. Механизмы обмена информацией о данных, требуемые для передачи данных по конвейеру, реализованы частично в рамках класса ProcessObject и частично в рамках класса DataObject.

Классы LightObject и Object являются иллюстрацией дихотомии классов ProcessObject и DataObject. Классы LightObject и Object предоставляют такие стандартные функции, как API для обмена объектами Events и средства поддержки многопоточности.

Внутренние процессы конвейера

На Рисунке 9.7 представлена UML-диаграмма последовательности вызовов, описывающая взаимодействия между объектами ProcessObject и DataObject в рамках упрощенного конвейера, состоящего из объектов ImageFileReader, MedianImageFilter и ImageFileWriter.

Совокупность взаимодействий делится на четыре фазы:

UML-диаграмма последовательности вызовов
Рисунок 9.7: UML-диаграмма последовательности вызовов

Процесс работы конвейера начинается с вызова приложением метода Update() последнего фильтра конвейера; в этом конкретном примере этот фильтр представлен объектом ImageFileWriter. Вызов метода Update() инициирует первую обратную последовательность вызовов. Это последовательность, которая начинается с последнего фильтра конвейера и распространяется в направлении первого фильтра.

Целью первой фазы является получение ответа на вопрос "Как много данных будет сгенерировано?" В форме кода этот вопрос реализован в рамках метода UpdateOutputInformation(). С помощью этого метода каждый фильтр рассчитывает объем данных изображения, который может быть получен на выходе в случае обработки заданного исходного объема данных. Учитывая то, что объем исходных данных должен быть известен до того, как фильтр сможет дать ответ на вопрос об объеме выходных данных, вопрос должен переадресовываться предыдущим фильтрам до того момента, как будет достигнут исходный фильтр, который может самостоятельно ответить на вопрос. В этом конкретном примере исходный фильтр представлен объектом ImageFileReader. Этот фильтр может установить объем выходных данных, получив информацию из файла изображения, который был выбран для чтения. После того, как первый фильтр конвейера отвечает на поставленный вопрос, последующие фильтры один за другим получают возможность вычисления соответствующих объемов выходных данных до того момента, как последний фильтр конвейера вычислит этот объем.

Вторая фаза, в ходе которой вызовы также осуществляются против направления конвейера, предназначена для информирования фильтров об объеме выходных данных, который они должны сгенерировать в ходе работы конвейера. Концепция запрашиваемых фрагментов (Requested Region) необходима для поддержки потоковой обработки данных в ITK. Она позволяет сообщать фильтрам конвейера о том, что не следует генерировать полное изображение, а следует обрабатывать только его фрагмент, являющийся запрашиваемым фрагментом. Это очень полезно в том случае, если имеющееся изображение не может быть полностью помещено в доступную оперативную память системы. Вызовы направлены от последнего фильтра к первому, причем каждый промежуточный фильтр модифицирует размер запрашиваемого фрагмента с учетом необходимых дополнительных границ исходного изображения, которые могут понадобиться фильтру для генерации фрагмента изображения заданного размера. В этом конкретном примере медианный фильтр обычно добавляет границу толщиной в 2 пикселя к исходному изображению. Таким образом, если фильтр записи запрашивает фрагмент размером 500 x 500 пикселей у медианного фильтра, медианный фильтр в свою очередь запросит фрагмент размером 502 x 502 пикселя у фильтра чтения, так как медианному фильтру с настройками по умолчанию требуется фрагмент размером 3 x 3 пикселя для вычисления значения одного пикселя результирующего изображения. Эта фаза реализована в форме кода в рамках метода PropagateRequetedRegion().

Третья фаза направлена на запуск выполнения расчетов в отношении данных запрошенного фрагмента. В ходе этой фазы вызовы также осуществляются против направления конвейера, а в форме кода она реализована в рамках метода UpdateOutputData(). Так как каждому фильтру требуются исходные данные для обработки и формирования выходных данных, вызовы изначально передаются предшествующим соответствующим фильтрам, что подразумевает распространение вызовов против направления конвейера. После возврата данных предыдущим фильтром, текущий фильтр приступает непосредственно к их обработке.

Четвертая и финальная фаза предусматривает выполнение вызовов по направлению конвейера и заключается в непосредственной обработке данных каждым из фильтров. В форме кода эта фаза представлена в рамках метода GenerateData(). Направление, совпадающее с направлением конвейера, является последствием не того, что каждый фильтр осуществляет вызовы методов следующего фильтра, а того факта, что вызовы UpadateOutputData() осуществляются в последовательности от первого к последнему фильтру конвейера. Таким образом, все вызовы осуществляются в направлении конвейера в соответствии со временем вызовов, а не в соответствии с тем, какой фильтр осуществляет запросы. Это пояснение важно, так как конвейер ITK соответствует концепции Pull Pipeline, в которой данные запрашиваются с конца конвейера и управление логикой осуществляется также с конца конвейера.

Фабрики

Одним из фундаментальных требований, предъявляемых во время проектирования к ITK, является возможность поддержки множества платформ. Это требование основывается на желании максимально расширить распространение тулкита путем реализации возможности его использования сообществом вне зависимости от предпочтительных платформ участников. В рамках проекта ITK был реализован шаблон проектирования фабрики (Factory design pattern) для решения задачи по поддержке фундаментальных различий множества аппаратных и программных платформ без ущерба совместимости решения с каждой из платформ.

Шаблон проектирования фабрик в ITK использует имена классов в качестве ключей для реестра конструкторов классов. Регистрация фабрик происходит в процессе работы приложения и может быть осуществлена путем простого размещения динамических библиотек в соответствующих директориях, где приложения на основе ITK производят их поиск при запуск. Эта возможность позволяет использовать существующий механизм для реализации модульной архитектуры очевидным и прозрачным образом. В результате упрощается процесс разработки расширяемых приложений для анализа изображений, удовлетворяющих требованиям предоставления постоянно расширяющегося набора функций анализа изображений.

Фабрики ввода/вывода

Механизм фабрик особенно важен при осуществлении операций ввода/вывода.

Обработка различий систем с помощью фасадов

Сообщество, занимающееся исследованием изображений, разработало очень большой набор форматов файлов для сохранения данных изображений. Многие из этих форматов проектировались и разрабатывались для специфических нужд и, следовательно, хорошо оптимизированы для хранения специфических типов изображений. В результате в сообществе постоянно разрабатываются и рекомендуются для использования новые форматы файлов изображений. Предсказывая эту ситуацию, команда разработчиков ITK спроектировала подходящую для простого расширения функций архитектуру ввода/вывода, в рамках которой достаточно просто регулярно добавлять поддержку все большего и большего набора форматов файлов.

Зависимости фабрик ввода/вывода
Рисунок 9.8: Зависимости фабрик ввода/вывода

Эта расширяемая архитектура для осуществления операций ввода/вывода основана на механизме фабрик, описанном в предыдущем разделе. Основное отличие заключается в том, что в случае системы для операций ввода/вывода фабрики ввода/вывода регистрируются в специализированном реестре, управляемом базовым классом ImageIOFactory, изображенным в верхнем левом углу Рисунка 9.8. Сами функции чтения и записи данных для различных форматов файлов изображений реализованы в рамках семейства классов ImageIO, изображенного справа на Рисунке 9.8. Эти служебные классы предназначены для создания экземпляров по требованию в тот момент, когда пользователь осуществляет чтение или запись изображения. Эти служебные классы не раскрываются в рамках кода приложений. Вместо прямого обращения к этим классам приложения должны взаимодействовать с классами фасадов:

Это два класса, при использовании которых в приложении может применяться подобный код:

reader->SetFileName("../image1.png");
reader->Update();

или:

writer->SetFileName("../image2.jpg");
writer->Update();

В обоих случаях вызов метода Update() приводит к запуску конвейера, с которым соединены рассматриваемые объекты ProcessObject. И объект чтения, и объект записи данных ведут себя как дополнительный фильтр конвейера. В случае объекта для чтения данных вызов метода Update() приводит к чтению соответствующего файла изображения и размещению данных в памяти. В случае объекта записи вызов метода Update() приводит к выполнению операций конвейера, в результате чего объект записи получает входные данные, и, наконец, завершается записью изображения на диск в файл определенного формата.

Эти классы фасадов скрывают от разработчика приложений внутренние сложности, возникающие из-за особенностей каждого из форматов файлов. Они скрывают даже признаки самого существования формата. Эти фасады спроектированы таким способом, что большую часть времени разработчикам приложений не требуется знать о том, какие форматы файлов могут быть прочитаны приложением. Стандартное приложение может просто использовать подобный код:

std::string filename = this->GetFileNameFromGUI();
writer->SetFileName( filename );
writer->Update();

Эти вызовы будут работать аналогично в том случае, если значение переменной filename будет представлено одной из следующих строк:

причем расширения файлов указывают на различные форматы файлов в каждом случае.

Знайте тип пикселя

Несмотря на содействие со стороны фасадных классов чтения и записи, разработчику приложений следует заботиться о том, какой тип пикселей требуется обрабатывать приложению. В контексте работы с медицинскими изображениями разумно ожидать того, что разработчик приложения будет знать о том, содержит ли исходное изображение магнитно-рзеонансную томограмму, маммографию или компьютерную томограмму и, следовательно, позаботится о выборе подходящего типа пикселя и разрешения изображения для каждого из этих различных типов изображений. Эти особенности типов изображений могут доставлять неудобства в случае использования настроек приложения, при которых пользователи хотят иметь возможность чтения любого типа изображения, что часто встречается при быстром создании прототипов и обучении. В контексте развертывания приложений для работы с медицинскими изображениями для эксплуатации в клиниках, однако, ожидается, что тип пикселей и разрешение изображений будут четко заданы и смогут определяться на основе типа предназначенного для обработки изображения. Конкретный пример, в котором приложение работает с трехмерными магнитно-резонанснвми томограммами, выглядит следующим образом:

typedef itk::Image< signed short, 3 >  MRImageType;
typedef itk::ImageFileWriter< MRImageType > MRIWriterType;
MRIWriterType::Pointer writer = MRIWriterType::New();
writer->Update();

Однако, существует ограничение того, насколько особенности форматов файлов изображений могут быть скрыты от разработчика приложений. Например, при чтении изображений из файлов форматов DICOM или RAW разработчику придется использовать дополнительные вызовы для точного указания характеристик имеющегося формата. Файлы формата DICOM наиболее часто встречаются в медицинских учреждениях, а файлы формата RAW все еще являются необходимым злом, предназначенным для обмена данными в процессе исследований.

Объединенные, но разделенные

Автономный характер каждой фабрики ввода/вывода и служебный класс ImageIO также были затронуты процессом разделения на модули. Обычно класс ImageIO зависит от специализированной библиотеки, предназначенной для работы со специфическим форматом файлов. Такими форматами, например, являются PNG, JPEG, TIFF и DICOM. В этих случаях сторонняя библиотека рассматривается как независимый модуль и специализированный код класса ImageIO, являющийся интерфейсом между кодом ITK и кодом сторонней библиотеки, также размещается в модуле. Таким образом, специфические приложения могут могут ограничить использование множества форматов файлов, которые не входят в сферу их применения и работать только с теми форматами файлов, которые окажутся полезными в ожидаемых сценариях применения данного приложения.

Как и в случае со стандартными фабриками, загрузка фабрик ввода/вывода может быть осуществлена в процессе работы приложения из динамических библиотек. Этот гибкий процесс загрузки фабрик упрощает использование специализированных самостоятельно разработанных форматов фалов без необходимости включения поддержки таких форматов файлов непосредственно в состав тулкита ITK. Загружаемые фабрики ввода/вывода были одним из наиболее успешных архитектурных решений проекта ITK. Они позволили достаточно просто разрешить сложную ситуацию без усложнения или запутывания кода. Не так давно подобная архитектура ввода/вывода была реализована для управления процессом чтения и записи файлов, содержащих пространственные преобразования в рамках семейства классов Transform.

Потоковая передача данных

Изначально тулкит ITK разрабатывался как набор инструментов для обработки изображений, полученных проектом Visible Human Project. В то время было совершенно ясно, что такой большой набор данных не сможет быть размещен в оперативной памяти компьютеров, которые обычно были доступны участникам сообщества исследователей медицинских изображений. Также данный набор данных все еще не может быть размещен в памяти стандартных настольных компьютеров, использующихся на сегодняшний день. Следовательно, одним из требований к разработке проекта Insight Toolkit было обеспечение возможности потоковой передачи данных изображения по конвейеру данных. Точнее, возможности обработки изображений больших размеров путем передачи блоков изображения через конвейер данных и последующей сборки результирующих блоков в конце конвейера.

Иллюстрация процесса потоковой передачи изображения
Рисунок 9.9: Иллюстрация процесса потоковой передачи изображения

Метод разделения области изображения проиллюстрирован на Рисунке 9.9 для конкретного примера медианного фильтра. Медианный фильтр вычисляет значение результирующего пикселя как статистическую медиану значений соседних пикселей исходного изображения. Размер границы из соседних пикселей является числовым параметром фильтра. В данном случае мы установили значение, равное 2 пикселям и это означает, что мы будем использовать соседние пиксели в радиусе 2 пикселей вокруг нашего результирующего пикселя. В результате нам необходим фрагмент изображения размером 5x5 пикселей с результирующим пикселем в центре и квадратной границей в 2 пикселя вокруг него. Обычно это называется Манхэттэн-радиусом (Manhattan radius). В тот момент, когда медианный фильтр получает запрос расчета значений пикселей запрашиваемого фрагмента выходного изображения, он обращается к предыдущему фильтру и просит его предоставить фрагмент большего, чем заданный с помощью спецификации запрашиваемого фрагмента размера, причем размер увеличивается на количество пикселей границы, в нашем случае на 2 пикселя. В специфическом случае, представленном на Рисунке 9.9, при запросе фрагмента 2 размером 100x25 пикселей, медианный фильтр передает запрос предыдущему фильтру, причем размер фрагмента устанавливается равным 100x29 пикселей. Вертикальный размер фрагмента, равный 29 пикселям, вычисляется как сумма 25 пикселей и размеров двух границ по 2 пикселя каждая. Следует отметить, что горизонтальный размер фрагмента не увеличивается в данном случае, так как этот размер является максимальным для рассматриваемого исходного изображения; следовательно, увеличенный размер, равный 104 пикселям (сумма 100 пикселей и размеров двух границ по 2 пикселя) уменьшен в соответствии с максимальным размером изображения, которое равно 100 пикселям по горизонтали.

Фильтры ITK, работающие с соседними пикселями, обрабатывают граничные условия одним из трех стандартных способов: устанавливают нулевое значение пикселей вне изображения, зеркально отражают значения пикселей по границе или повторяют значения граничных пикселей вне изображения. В случае медианного фильтра используется граничное условие Неймана нулевого потока (zero-flux Neumann boundary condition), которое просто означает, что значения пикселей вне границы фрагмента изображения устанавливаются равными значениям последних найденных пикселей в границах изображения.

Хорошо хранимым литературой по обработке изображений небольшим секретом является тот факт, что большинство сложностей реутилизации фильтров изображений относится к корректной обработке граничных условий. Это определенный симптом различий между теоретическими заданиями, которые встречаются во многих книгах, и практическим опытом разработки программного обеспечения для обработки изображений. В рамках проекта ITK эта задача была решена путем реализации набора классов итераторов изображения и соответствующего семейства классов для расчета граничных условий. Два этих семейства вспомогательных классов скрывают от фильтров изображений сложности управления граничными условиями в N-ном количестве плоскостей.

Процесс потоковой передачи данных изображения начинается вне фильтра, обычно с помощью классов ImageFileWriter или StreamingImageFilter. Два этих класса реализуют функции потоковой передачи данных, разделяя изображение на несколько фрагментов в соответствии с требованиями разработчика. После этого в ходе вызова их метода Update() они выполняют итерацию, запрашивая каждый из промежуточных фрагментов изображения. На этом этапе используются возможности API SetRequestRegion(), проиллюстрированного с помощью Рисунка 9.7. Этот вызов позволяет ограничить область расчета значений пикселей изображения с помощью фильтров фрагментом этого изображения.

Код приложения, позволяющий осуществить потоковую обработку данных, выглядит аналогично следующему:

median->SetInput( reader->GetOutput() );
median->SetNeighborhoodRadius( 2 );
writer->SetInput( median->GetOutput() );
writer->SetFileName( filename );
writer->SetNumberOfStreamDivisions( 4 );
writer->Update();

единственным новым элементом которого является вызов SetNumberOfStreamDivisions(), который устанавливает количество фрагментов, на которые будет разделено изображение для потоковой обработки с помощью конвейера. Для соответствия Рисунку 9.9 мы использовали значение, равное четырем и устанавливающее количество фрагментов для разделения изображения. Это значит, что объект writer выполнит запуск фильтра median четыре раза, причем каждый раз фильтру будет передаваться отличная структура запрашиваемого фрагмента.

Существуют интересные сходства между процессом потоковой обработки данных и процессом параллельной работы нескольких экземпляров заданного фильтра. В обоих случаях процесс основывается на возможности разделения задачи по обработке изображения путем разделения изображения на отдельные фрагменты, которые могут обрабатываться независимо. В случае потоковой обработки данных фрагменты изображения обрабатываются последовательно один за другим, а в случае параллельной обработки фрагменты изображения привязываются к отдельным потокам, которые в свою очередь привязываются к отдельным ядрам процессора. В конечном счете алгоритмический характер работы фильтров устанавливает то, возможно ли разделить результирующее изображение на фрагменты, которые будут обрабатываться отдельно на основании соответствующего набора фрагментов исходного изображения. В ITK функции потоковой и параллельной обработки практически ортогональны в том смысле, что существует API для управления процессом потоковой обработки существует отдельный API, предназначенный для поддержки реализации базовых функций параллельных вычислений с использованием множества потоков и фрагментов разделяемой памяти.

Потоковая обработка, к сожалению, не совместима со всеми типами алгоритмов. Особые случаи, в которых потоковая обработка не может быть осуществлена:

К счастью, с другой стороны, структура конвейера данных ITK поддерживает потоковую передачу данных для различных фильтров преобразований, используя тот факт, что все фильтры создают свое результирующее изображение и, следовательно, не перезаписывают область памяти, содержащую исходное изображение. Это приводит к затратам памяти, так как конвейеру приходится одновременно резервировать память и для исходных, и для результирующих изображений. Фильтры, осуществляющие такие операции, как переворот изображений, перестановка осей и геометрические изменения, попадают в эту категорию. В этих случаях конвейер данных управляет сопоставлением фрагментов исходного и результирующего изображений, требуя, чтобы каждый фильтр предоставлял метод с названием GenerateInputRequestedRegion(), который принимает в качестве аргумента прямоугольную область результирующего изображения. Этот метод производит расчет значений пикселей прямоугольного фрагмента исходного изображения, которые потребуются этому фильтру для расчета значений пикселей этого определенного прямоугольного фрагмента результирующего изображения. Этот постоянный обмен данными в рамках конвейера данных позволяет поставить в соответствие каждому блоку результирующего изображения соответствующий блок исходного изображения, который требуется для проведения расчета значений пикселей.

Если быть более точным, то можно сделать вывод о том, что ITK поддерживает поточную передачу данных изображения, но только при использовании "поточных" алгоритмов. Тем не менее, для того, чтобы быть прогрессивными в отношении оставшихся алгоритмов, мы должны трактовать это утверждение не как жалобу о том, что "невозможно реализовать поточную обработку данных при использовании этих алгоритмов", а как утверждение о том, что "наш стандартный подход к потоковой передаче данных не совместим с этими алгоритмами" на данный момент и мы надеемся, что в будущем сообщество изобретет новые техники для решения этой проблемы.

9.3. Выученные уроки

Повторное использование

Принцип повторного использования может быть также назван "уменьшением избыточности". В случае ITK это достигается с помощью подхода, включающего три принципа:

Большая часть этих пунктов сегодня может показаться банальной и очевидной, но в в момент начала разработки ITK в 1999 году некоторые из них не были настолько очевидными. В частности, при поддержке большинством компиляторов языка C++ шаблонов, эта поддержка в точности не соответствовала однозначному стандарту. Даже сегодня такие решения, как применение парадигмы обобщенного программирования и использование реализации с широким применением шаблонов продолжают вызывать споры в сообществе. Это утверждение подчеркивается фактом существования сообществ, участники которых предпочитают использовать ITK с помощью уровня кода для совместимости с языками Python, Tcl или Java.

Обобщенное программирование

Применение парадигмы обобщенного программирования было одной из определяющих особенности реализации ITK черт. В 1999 году это решение было сложным, так как в то время поддержка компиляторами шаблонов C++ была значительно фрагментированной и стандартная библиотека шаблонов (Standard Template Library (STL)) все еще рассматривалась в некоторой степени как экзотическое дополнение.

Обобщенное программирование было применено в ITK путем использования шаблонов C++ для обобщенной реализации концепций и повышения степени повторного использования кода таким способом. Стандартным примером использования параметризации шаблонов C++ в ITK является класс Image, экземпляр которого может быть создан следующим образом:

typedef unsigned char PixelType;
const unsigned int Dimension = 3;
typedef itk::Image< PixelType, Dimension > ImageType;
ImageType::Pointer image = ImageType::New();

В этом коде разработчик приложения выбирает тип, используемый для представления пикселей изображения, а также количество плоскостей изображения в виде сетки в пространстве. В примере мы выбрали для использования 8-битные пиксели, представленные с помощью значений типа unsigned char для 3-х мерного изображения. Благодаря низкоуровневой обобщенной реализации, в ITK возможно создание экземпляров классов изображений для любых типов пикселей с любым количеством плоскостей.

Для возможности записи этих выражений разработчикам ITK пришлось реализовать класс Image с особой осторожностью в отношении предположений о типе пикселей. Как только разработчик приложения создал экземпляр класса изображения определенного типа, он может создавать объекты этого типа или перейти к созданию экземпляров классов фильтров изображений, типы которых, в свою очередь, зависят от типа изображения. Например: typedef itk::MedianImageFilter< ImageType, ImageType> FilterType; FilterType::Pointer median = FilterType::New();

Алгоритмические особенности некоторых фильтров изображений ограничивают набор актуальных типов пикселей, который они поддерживают. Например, некоторые фильтры изображений ожидают, что тип пикселей изображения будет представлен целочисленными скалярными величинами, в то время, как некоторые другие фильтры ожидают типа пикселя в виде векторов из величин с плавающей точкой. При создании экземпляров классов изображений с неподходящими типами пикселей эти фильтры станут причиной ошибок компиляции или ошибочных результатов расчетов значений пикселей изображения. Для предотвращения некорректного создания объектов и упрощения поиска причин ошибок компиляции в рамках ITK был применен метод концептуальных проверок (concept checking), основанный на принудительном использовании определенных ожидаемых возможностей типов с целью раннего выявления ошибок с выводом понятных человеку сообщений об ошибках.

Шаблоны C++ также используются в определенных системах тулкита в форме шаблонного метапрограммирования (Template Metaprogramming) для повышения производительности кода, а в особенности для разворачивания циклов, которые используются для расчета значений низкоразмерных векторов и матриц. По иронии судьбы, со временем мы обнаружили, что определенные компиляторы научились определять места, где необходимо осуществить разворачивание циклов и в некоторых случаях им больше не требуется выражений шаблонного метапрограммирования.

Знать, когда остановиться

Также существует риск того, что при разработке будет "сделано очень много хороших вещей", подразумевающий риск чрезмерного использования шаблонов или чрезмерного использования макросов. Достаточно просто зайти слишком далеко и в итоге создать новый языка программирования на основе C++, реализованный с помощью шаблонов и макросов. Это тонкая грань, которая требует постоянного внимания со стороны команды разработчиков для уверенности в том, что возможности языка программирования используются должным образом без злоупотреблений.

В качестве конкретного примера можно привести широко используемую схему явного именования типов с помощью оператора typedef С++, которая оказалась достаточно важной. Эта практика позволяет выполнить две задачи: с одной стороны, она позволяет использовать понятные для человека и информативные имена, описывающие характер и назначение типа; с другой стороны, она позволяет быть уверенным в том, что тип используется последовательно в рамках тулкита. В качестве примера следует упомянуть о том, что в ходе рефакторинга тулкита при подготовке версии 4.0 были приложены большие усилия для того, чтобы установить случаи использования таких целочисленных типов C++, как int, unsigned int, long и unsigned long и заменить их на типы, названные в соответствии с надлежащими концепциями, связанными с представляемыми переменными данными. Эта часть работы, направленной на предоставление возможности использования 64-битных типов для обработки изображений объемом более четырех гигабайт на всех платформах, была наиболее сложной. Данная задача имела крайне важное значение для последующего использования ITK в области микроскопии и дистанционного зондирования, где изображения объемом в десятки гигабайт встречаются достаточно часто.

Возможность поддержки кода

Архитектура удовлетворяет требованиям, направленным на снижение сложности поддержки кода.

Эти характеристики сокращают затраты труда на сопровождение кода в следующих случаях:

По мере того, как разработчики вовлекаются в процесс регулярной поддержки кода, они могут столкнуться с некоторыми "стандартными ошибками", а именно:

Благодаря практикам взаимодействия сообществ разработчиков приложений с открытым исходным кодом, многие из этих ошибок в конце концов обнаруживаются с помощью вопросов, обычно задаваемых в списках рассылки или в отправляемых напрямую пользователями сообщениях об ошибках. После рассмотрения множества подобных случаев разработчики учатся писать код, который "подходит для сопровождения". Некоторые из свойств этого кода относятся и к стилю оформления кода, и к самой организации кода. На наш взгляд, разработчик достигает мастерства только после некоторого времени работы - хотя бы года - по сопровождению кода и рассмотрения "всех случаев, в которых код может работать некорректно".

Невидимая рука

Программное обеспечение должно выглядеть так, как будто оно было разработано одним человеком. Лучшими являются разработчики, создающие код, который может развиваться любым другим разработчиком, если основного разработчика собьет автобус, упоминаемый в известной фразе. Мы достигли осознания того, что любой признак "индивидуального стиля разработчика" является указателем на дефект программного обеспечения.

Для применения и распространения информации о необходимости использования единого стиля оформления кода могут использоваться следующие подтвердившие свою высокую эффективность инструменты:

Следует подчеркнуть, что единый стиль кода не просто улучшает его эстетическую привлекательность, а на самом деле оказывает влияние на экономические показатели проекта. Исследование совокупной стоимости владения (Total Cost of Ownership (TCO)) программными проектами установило, что в течение цикла жизни проекта стоимость его поддержки будет составлять около 75% от TCO и, учитывая то, что стоимость обслуживания регулярно повышается, она обычно превышает затраты на полную разработку проекта за пять первых лет цикла жизни программного проекта. (Обратитесь к книге "Software Development Cost Estimating Handbook", Volume I, Naval Center for Cost Analysis, Air Force Cost Analysis Agency, 2008.) Затраты на сопровождение проекта оцениваются в 80% от той работы, которую на самом деле выполняют разработчики программного обеспечения и при осуществлении этой деятельности большая часть времени разработчиков отводится на чтение кода, созданного другим человеком и выяснение того, для выполнения каких действий этот код предназначен. (обратитесь к книге "Clean Code, A Handbook of Agile Software Craftsmanship", Robert C. Martin, Prentice Hall, 2009). Единый стиль кода чудесным образом сокращает время, затрачиваемое разработчиками на погружение в код из нового файла проекта и понимание этого кода до того, как они смогут как-либо модифицировать его. К тому же, сокращается вероятность того, что разработчики неправильно поймут код и модифицируют его, создав новые ошибки в ходе добросовестной попытки исправления старых (The Art of Readable Code, Dustin Boswell, Trevor Foucher, O'Reilly, 2012).

Ключевым фактором эффективного применения этих инструментов является проверка соответствия следующим критериям:

Рефакторинг

Проект ITK был начат в 2000 году и постоянно развивался до 2010 года. В 2011 году благодаря финансовым вливаниям из федеральных инвестиций команда разработчиков получила по истине уникальную возможность занятия рефакторингом кода. Финансирование было осуществлено Национальной библиотекой медицины в рамках этапа реализации инициативы по оздоровлению экономики Америки и реинвестированию (American Recovery and Reinvestment Act (ARRA)). Эта работа не была второстепенной. Представьте, что вы работали над частью программного обеспечения в течение более чем десяти лет и вам предоставили возможность улучшения ее кода; что бы вы изменили?

Такая возможность реализации расширенного рефакторинга появляется очень редко. В течение предыдущих десяти лет мы ежедневно проводили небольшие локальные рефакторинги, очищая определенные уголки тулкита после того, как оказывались в них. Этот продолжительный процесс очистки и улучшения кода использовался благодаря преимуществам взаимодействия участников сообществ проектов с открытым исходным кодом, а безопасность процесса обеспечивалась инфраструктурой тестирования на основе CDash, которая обычно проверяет около 84% кода тукита. Следует отметить, что в отличие от нашего показателя, среднее покрытие кода системами промышленного тестирования оценивается только в 50%.

Среди множества вещей, измененных в процессе рефакторинга, наиболее относящимися к вопросам архитектуры являются:

Поддержка развития проекта, основанная на инкрементальных модификациях - производящихся в ходе выполнения таких задач, как добавление возможностей фильтра изображения, улучшение производительности заданного алгоритма, работа на основе сообщения об ошибке и улучшение документации определенных фильтров изображений - хорошо функционирует при локальном улучшении определенных классов C++. Однако, расширенный рефакторинг необходим для инфраструктурных модификаций, которые затрагивают большое количество классов по всем направлениям, таких, как те, что были перечислены ранее. Например, набор изменений, необходимых для поддержки файлов объемом более 4 GB был, вероятно, одним из самых больших патчей, которые когда-либо применялись по отношению к ITK. Этот процесс потребовал модификации сотен классов и не мог быть выполнен инкрементально без серьезных неудобств. Процесс разделения кода на модули является еще одним примером задачи, которая не могла быть выполнена инкрементально. На самом деле этот процесс повлиял на всю организацию тулкита, на то, как работает инфраструктура тестирования, как производится управление тестовыми данными, как тулкит упаковывается и распространяется, а также на то, как новые фрагменты исходного кода должны быть сформированы для включения в состав тулкита в будущем.

Воспроизводимость

Одним из ранних уроков, выученных при разработке ITK, являлся тот факт, что многие опубликованные описания алгоритмов из области интересов команды разработчиков были не настолько просты в реализации, как нам казалось. Исследователи, специализирующиеся на вычислительных алгоритмах, зачастую чрезмерно радуются разработке нового алгоритма и избегают практической работы по созданию программного обеспечения, заявляя о том, что это "всего лишь детали реализации".

Такое безответственное отношение достаточно опасно для всей области исследований, так как оно подразумевает отрицание необходимости личного опыта, связанного с реализацией кода и его корректным использованием. В результате большинство опубликованных описаний алгоритмов просто не воспроизводимо и когда исследователи и студенты пытаются использовать подобные техники, они затрачивают большое количество времени в процессе реализации и предоставляют вариации исходной работы. На самом деле на практике достаточно сложно проверить, совпадает ли реализация с тем, что описано на бумаге.

Проект ITK нарушил такое положение вещей в хорошем смысле и восстановил культуру самостоятельной работы в этой области исследований, где уже привыкли к теоретическим обоснованиям и научились избегать работы по проведению экспериментов. Новая культура, привнесенная проектом ITK, является практичной и прагматичной культурой, в рамках которой о преимуществах программного обеспечения судят по практическим результатам его использования, а не по видимой сложности, которая считается преимуществом в некоторых научных публикациях. Оказывается, что на практике наиболее эффективными методами обработки изображений являются те методы, которые выглядели бы очень просто для включения в научную публикацию.

Культура создания воспроизводимых алгоритмов является продолжением философии разработки через тестирование и систематически приводит к повышению качества программного обеспечения; большей ясности кода, упрощению его чтения, уменьшению количества ошибок и точности реализации алгоритмов.

Для заполнения бреши, заключающейся в недостатке воспроизводимых публикаций, сообщество ITK создало вебсайт Insight Journal. Доступ к размещаемым на нем публикациям открыт для всех, причем от авторов публикаций требуется предоставление исходного кода, данных, параметров и тестов для возможности проверки воспроизводимости. Статьи публикуются в сети менее чем через 24 часа после отправки. После этого они становятся доступны для обзора любым участником сообщества. Читатели получают полный доступ ко всем материалам, прилагающимся к статье, а именно: исходному коду, данным, параметрам и сценариям для тестирования. Insight Journal стал продуктивной средой для обмена новыми фрагментами кода, которые начинают свой путь по направлению к кодовой базе проекта. Insight Journal недавно получил 500-ю статью и продолжает использоваться как официальный источник нового кода для добавления в состав ITK.

10.1. Анатомия сообщения

Одной из основных структур данных системы Mailman является сообщение электронной почты (email message), представленное объектом message. Многие интерфейсы, функции и методы в рамках системы принимают три аргумента: объект списка рассылки, объект сообщения и словарь метаданных, используемый для записи и обмена данными состояния в ходе обработки сообщения системой.

Сообщение, содержащее текст, изображения и звуковой файл; тип MIME
Рисунок 10.1: Сообщение, содержащее текст, изображения и звуковой файл; тип MIME

При подробном рассмотрении сообщение электронной почты оказывается простым объектом. Оно состоит из множества разделенных точкой с запятой пар ключ-значение, называемых заголовками, после которых следует пустая строка, отделяющая заголовки от сообщения. Это текстовое представление должно облегчать разбор, генерацию, исследование и манипуляции с сообщениями, но на самом деле оно стремительно усложнилось. Существует несчетное количество стандартов RFC, которые описывают все вероятные возможности, такие, как обработка сложных типов данных, представленных изображениями, аудиофайлами и другими типами. Сообщение электронной почты может содержать текст на английском языке в кодировке ASCII или текст на любом другом языке в любой существующей кодировке. Основная структура сообщения электронной почты заимствовалась снова и снова для использования в рамках других протоколов, таких, как NNTP и HTTP, причем все эти протоколы отличаются в значительной степени. В ходе нашей работы над системой Mailman была начата разработка нескольких библиотек исключительно для обработки всех возможных случаев использования данного формата (обычно называемого "RFC822" в соответствии с принятым в 1982 году стандартом IETF). Библиотеки для работы с сообщениями электронной почты, изначально разрабатываемые для применения в рамках системы GNU Mailman, были включены в стандартную библиотеку языка Python, где их разработка продолжилась в направлении улучшения стабильности работы и соответствия стандартам.

Сообщения электронной почты могут выступать в качестве контейнеров для других типов данных, как это описано в различных стандартах MIME. Контейнер сообщения (container message part) может содержать закодированное изображение, аудиофайл или любые бинарные или текстовые данные, включая другие контейнеры. В приложениях для работы с электронной почтой они известны под названием "вложения" (attachments). На Рисунке 10.1 показана структура сложного сообщения MIME. Прямоугольники со сплошными границами являются контейнерами, прямоугольники со штриховыми границами являются закодированными с помощью алгоритма Base64 бинарными данными, а прямоугольник с пунктирными границами - текстовым сообщением.

Контейнеры могут также быть произвольно вложенными; в этом случае они называются мультиконтейнерами (multipart) и фактически могут находиться на достаточно глубоком уровне вложенности. При этом любое сообщение электронной почты, независимо от его сложности, может быть представлено в виде дерева с единственным объектом сообщения на вершине. В рамках системы Mailman мы обычно рассматриваем сообщение как дерево объектов (message object tree) и передаем это дерево, ссылаясь на основной объект сообщения. На Рисунке 10.2 показано дерево объектов сообщения с мультиконтейнерами, схематично изображенного на Рисунке 10.1.

Дерево объектов для сложного MIME-типа сообщения электронной почты
Рисунок 10.2: Дерево объектов для сложного MIME-типа сообщения электронной почты

Система Mailman практически всегда модифицирует оригинальное сообщение каким-либо образом. Иногда трансформации могут быть весьма незначительными, заключающимися в добавлении или удалении заголовков. Иногда мы полностью изменяем структуру дерева объектов сообщения, например, в том случае, когда фильтр содержимого удаляет определенные типы данных, такие, как текст в формате HTML, изображения или другие не текстовые данные. Система Mailman может даже удалить описание типа MIME "multipart/alternatives" в том случае, если сообщение содержит и текстовую часть и текстовые данные, использующие какой-либо тип разметки, или добавить дополнительные части сообщения, содержащие информацию о списке рассылки.

В общем случае система Mailman разбирает актуальное (on the wire) байтовое представление сообщения всего один раз в тот момент, когда сообщение передается системе. С этого момента система работает только с деревом объектов сообщения до тех пор, пока оно не готово для отправки на используемый почтовый сервер. В этот момент система Mailman преобразует дерево объектов обратно в байтовое представление. В ходе обработки сообщения Mailman сохраняет дерево объектов сообщения (с помощью модуля pickle) для хранения и последующего извлечения из файловой системы. Модуль pickle предоставляет функции, реализующие технологию языка Python для преобразования любого объекта Python, включая все его дочерние объекты, в последовательность байт (сериализации) и отлично подходит для оптимизации процесса обработки деревьев объектов сообщений электронной почты. Также доступна функция преобразования этой последовательности байт в активный объект (десериализации). Храня эти последовательности байт в файлах, программы на языке Python получают в свое распоряжение постоянное хранилище данных, требующее малых затрат ресурсов.

10.2. Список рассылки

Объект списка рассылки (mailing list object), очевидно, является еще одним основным объектом системы Mailman и большинство операций в рамках Mailman осуществляется именно со списком рассылки; эти операции:

и так далее. Практически каждая операция в системе Mailman принимает ссылку на список рассылки в качестве аргумента - это фундаментальная концепция. Объекты списков рассылки были подвергнуты радикальным архитектурным изменениям в версии 3 системы Mailman для повышения их эффективности и гибкости.

Одним из ранних архитектурных решений, принятых John Viega, был способ представления объекта списка рассылки в рамках системы. Для представления этого центрального типа данных он выбрал класс Python с множеством базовых классов, каждый из которых реализовывал небольшую часть функций списка рассылки. Эти работающие совместно базовые классы, называемые смешанными классами (mixin classes), были реализацией разумного способа организации кода, позволяющей просто добавлять необходимые совершенно новые функции. После подключения нового базового смешанного класса основной класс MailList может просто получить возможность выполнения каких-либо новых и замечательных действий.

Например, для добавления автоответчика в версию 2 системы Mailman был создан смешанный класс, который содержал специфические для данной функции данные. Данные должны автоматически инициализироваться при создании нового списка рассылки. Смешанный класс также предоставляет методы, необходимые для поддержки функции автоответчика. Эта структура стала более полезной при проектировании постоянного хранилища объекта списка рассылки MailList.

Другим ранним архитектурным решением, принятым John Viega, является использование модуля pickle языка Python для постоянного хранения данных состояния объекта MailList.

В версии 2 системы Mailman данные состояния объекта MailList сохраняются в файле с именем config.pck, который является простым представлением словаря объекта MailList, сформированным с использованием функций модуля pickle. Каждый объект языка Python имеет соответствующий атрибут в форме словаря с названием __dict__. Таким образом, сохранение объекта списка рассылки заключается в простом преобразовании его словаря __dict__ с использованием функций модуля pickle в файл, а его загрузка - в чтении сохраненных данных из файла и реконструкции словаря __dict__.

Следовательно, когда смешанный класс добавляется для реализации каких-либо новых функций, все атрибуты смешанного класса автоматически преобразуются в файл и реконструируются соответствующим образом. Единственным дополнительным действием, которое нам приходится выполнять, является продержка версии схемы (schema version number) для возможности автоматического обновления устаревших объектов списков рассылок при добавлении новых атрибутов с помощью смешанных классов, так как сохраненное представление устаревших объектов MailList не будет содержать этих новых атрибутов.

Насколько бы не был удобен данный подход, архитектура смешанных объектов и постоянное хранилище данных с использованием функций модуля pickle не выдержали своего собственного веса. Администраторы сайтов часто просили предоставить способы доступа к конфигурационным переменным с использованием внешних систем, не основанных на технологиях языка Python. Но протокол представления данных модулем pickle является в полной мере специфичным для языка Python, поэтому объединение всех важных данных в файлах рассматриваемого формата в данном случае не является разумным. Также, ввиду того, что все данные состояния списка рассылки сохранялись в файле config.pck и система Mailman использовала множество процессов, которые должны были читать, модифицировать и записывать данные состояния списка рассылки, нам пришлось реализовать взаимные, работающие с NFS блокировки на основе файлов для того, чтобы быть уверенными в сохранности данных. В любой момент, когда какой-либо процесс системы Mailman хочет изменить данные состояния списка рассылки, он должен установить блокировку, записать измененные данные, после чего снять блокировку. Даже операции чтения могут потребовать перезагрузки соответствующего списку рассылки файла config.pck, так как некоторый другой процесс может изменить данные до выполнения операции чтения. Эта последовательность операций над данными списка рассылки была ужасно медленной и неэффективной.

По этим причинам система Mailman версии 3 хранит все данные в базе данных SQL. По умолчанию используется база данных SQLite 3, хотя эта настройка может быть просто изменена, так как в версии 3 системы Mailman применяется технология объектно-реляционного отображения (Object Relational Mapper) с названием Strom, поддерживающая большое количество баз данных. Поддержка PostgreSQL была добавлена всего лишь с помощью нескольких строк кода и администратор сайта может включить ее просто изменив одну из переменных конфигурации.

Другой, большей проблемой, присущей версии 2 системы Mailman, является разделенность списков рассылки. Обычно операции администрирования затрагивают множество списков рассылки, а иногда и все. Например, пользователь может захотеть временно заблокировать все свои подписки во время отпуска. Или администратор сайта может захотеть добавить какое-либо предупреждение в сообщение приветствия для всех списков рассылки своей системы. Даже простая операция по установлению того, на какой список подписан пользователь с заданным адресом, требует восстановления данных состояния каждого списка рассылки системы, так как информация о подписчиках также хранится в файле с именем config.pck.

Другая проблема заключалась в том, что каждый файл config.pck хранился в директории с именем, соответствующем названию списка рассылки, но приложение Mailman изначально проектировалось без учета возможности использования виртуальных доменов. Это обстоятельство привело к очень неприятной ситуации, в которой два списка рассылки не могли иметь одно и то же название в различных доменах. Например, если вы владеете и доменом example.com, и доменом example.org и при этом хотите, чтобы они были независимы и использовали отдельные списки рассылки с названиями support, вы не сможете реализовать эту идею в случае использования версии 2 системы Mailman без модификаций кода, использования не поддерживаемой точки вызова функций или определенных мер для обхода ограничения, которые позволяют принудительно устанавливать отличное название списка рассылки незаметно для пользователей и широко используются такими популярными сайтами, как SourceForge.

Эта проблема была решена в версии 3 системы Mailman путем изменения способа идентификации списков рассылки наряду с переносом всех данных в традиционную базу данных. Первичный ключ (primary key) таблицы списка рассылки является полностью определенным именем списка рассылки (fully qualified list name) или, как вы вероятнее воспримите, почтовым адресом. Следовательно, support@example.com и support@example.org представляются отдельными строками в таблице списков рассылки и могут просто сосуществовать в рамках одной системы Mailman.

10.3. Обработчики

Сообщения перемещаются в рамках системы с помощью наборов независимых процессов, называемых обработчиками (runners). Изначально введенные в качестве инструмента для предсказуемой обработки всех файлов сообщений из очереди, найденных в определенной директории, на данный момент обработчики существуют в виде нескольких независимых постоянно функционирующих процессов, которые выполняют специфическую задачу и находятся под управлением ведущего процесса; более подробное описание будет приведено ниже. В том случае, если обработчик управляет файлами в директории, он называется обработчиком очереди (queue runner).

Система Mailman принципиально разрабатывается как однопоточное приложение даже при наличии возможностей параллельного выполнения процессов. Например, система Mailman может принимать сообщения от почтового сервера одновременно с отправкой сообщений принимающим сторонам, обработкой нагрузок или архивацией сообщения. Параллелизм в рамках системы Mailman достигается путем использования множества процессов в виде этих самых обработчиков. Например, существует обработчик очереди входящих сообщений (incoming queue runner), задачей которого является прием (или отклонение) сообщений от используемого сервера электронной почты. Также существует обработчик очереди исходящих сообщений (outgoing queue runner), задачей которого является взаимодействие с используемым SMTP-сервером для отправки сообщений конечным адресатам. Наряду с описанными существуют обработчики очереди архивирования (archiver queue runner), очереди уведомлений (bounce processing queue runner), обработчик очереди пересылки сообщений серверу NNTP (queue runner for forwarding messages), обработчик создания каталога (runner for composing digests) и некоторые другие обработчики. Обработчики, не управляющие очередями включают обработчик с реализацией локального протокола пересылки почты (Local Mail Transfer Protocol) и обработчик с реализацией административного HTTP-сервера.

Каждый обработчик очереди ответственен за отдельную директорию, т.е., за свою очередь. Хотя обычная система Mailman может превосходно работать, выделяя по одному процессу на очередь, мы можем применить сложный алгоритм для параллельного исполнения задач в рамках отдельной директории очереди, не используя при этом каких-либо типов взаимодействий и блокировок. Секретом успеха является способ именования файлов в директории очереди.

Как упоминалось ранее, каждое сообщение, которое перемещается в системе также сопровождается словарем для метаданных, который накапливает данные состояния и позволяет независимым компонентам системы Mailman взаимодействовать друг с другом. Библиотека pickle языка Python позволяет производить прямое и обратное преобразование множества объектов в один файл, поэтому мы можем преобразовать в один и тот же файл и дерево объектов сообщения и словарь метаданных.

В рамках системы Mailman существует основной класс с именем Switchboard, который предоставляет интерфейс для выполнения операций помещения в очередь (т.е. записи) и извлечения из очереди (т.е. чтения) дерева объектов сообщения и словаря метаданных в отношении файлов из директории определенной очереди. Каждая директория очереди имеет как минимум один соответствующий ей экземпляр класса Switchboard и каждый экземпляр обработчика очереди имеет по одному экземпляру класса Switchboard.

Все созданные с использованием библиотеки pickle файлы имеют расширение .pck, хотя вы также можете обнаружить в очереди файлы с расширениями .bak, .tmp, и .psv. Они используются для реализации двух священных принципов функционирования системы Mailman: ни один из файлов не должен быть потерян и ни одно из сообщений не должно быть доставлено более чем один раз. Но обычно система функционирует в нормальном режиме, поэтому эти файлы могут быть обнаружены очень редко.

Как было показано, для особо загруженных сайтов система Mailman предоставляет возможность запуска более чем одного процесса обработчика для каждой из директорий очередей в полностью параллельном режиме без взаимодействия между ними или необходимости блокировок для обработки файлов. Этого удается добиться путем использования хэшей SHA1 для именования файлов, после чего разрешения отдельному обработчику очереди обрабатывать только определенный участок диапазона хэш-значений. Таким образом, если сайту необходимо использовать два обработчика для очереди уведомлений (bounces queue), один обработчик будет обрабатывать файлы из верхней половины диапазона хэш-значений, а другой будет обрабатывать файлы из нижней половины диапазона хэш-значений. Хэши рассчитываются с использованием данных дерева объектов сообщения из файла, имени списка рассылки, для которого предназначено сообщение и метки времени. Хэши SHA1 эффективно распределены и, следовательно, в среднестатистической директории с двумя обработчиками очередей у каждого процесса будет примерно одинаковый объем работы. А так как диапазон хэш-значений может быть статически разделен, эти процессы могут работать в одной директории очереди без необходимости вмешательства в работу друг друга и взаимодействия.

Существует интересное ограничение данного алгоритма. Так как алгоритм разделения ставит в соответствие каждому обработчику одни или несколько битов хэша, количество обработчиков для каждой директории очереди должно быть кратным 2. Это значит, что могут использоваться 1, 2, 4 или 8 процессов обработчиков для каждой очереди, но, например, не 5. На практике это ограничение никогда не приводило к проблемам, так как только нескольким сайтам может потребоваться более 4 обработчиков для работы в условиях их нагрузки.

Существует другой побочный эффект использования этого алгоритма, который приводил к проблемам в период раннего проектирования этой системы. Несмотря на непредсказуемость процесса доставки сообщений электронной почты в общем случае, для пользователя является наиболее удобной обработка файлов очереди в последовательности FIFO, таким образом, чтобы направляемые в список рассылки ответы отсылались в относительно хронологическом порядке. Игнорирование этого правила может привести в замешательство участников списка рассылки. Но использование хэшей SHA1 в качестве имен файлов препятствует использованию меток времени, при этом следует избегать вызова функции stat() в отношении файлов очереди по причинам, связанным с производительностью, а также распаковки содержимого сообщения (т.е. чтения метки времени из метаданных).

Решение в рамках системы Mailman заключалось в расширении алгоритма именования файлов для включения в состав имени префикса с меткой времени в форме числа, отражающего количество секунд, прошедших с начала эпохи (т.е. <метка времени>+<хэш sha1>.pck). Каждый обход очереди обработчик начинает с вызова метода os.listdir(), который возвращает список всех файлов в директории очереди. После этого для каждого файла производится разбор имени файла и игнорируются все имена файлов, хэши SHA1 которых не соответствуют диапазону хэшей обработчика. После этого обработчик сортирует оставшиеся файлы на основе данных из части имен, содержащей метку времени. Утверждение о том, что при использовании множества обработчиков очередей, каждый из которых обрабатывает определенный диапазон хэш-значений, могут возникнуть проблемы с распределением файлов между параллельно функционирующими обработчиками, является истинным, но на практике распределение на основе меток времени достаточно для удовлетворения ожиданий конечных пользователей в плане последовательной доставки сообщений.

На практике этот метод работал очень хорошо как минимум в течение 10 лет с проводимыми время от времени исправлениями незначительных ошибок или доработками для использования в частных случаях и в условиях возникновения ошибок. Это одна из наиболее стабильных частей системы Mailman, которая была портирована практически без изменений из Mailman 2 в Mailman 3.

10.4 Ведущий обработчик

При использовании всех описанных процессов обработчиков системе Mailman требуется простой способ их запуска и остановки; именно с этой целью был создан процесс ведущего обработчика, следящего за состоянием других обработчиков. У него должна быть возможность управления и обработчиками очередей, и обработчиками, которые не управляют очередями. Например, в версии 3 системы мы принимаем сообщения от используемого почтового сервера посредством протокола LMTP, который аналогичен протоколу SMTP, но позволяет выполнять только локальную доставку почтовых сообщений и, следовательно, может быть значительно упрощен, так как при его использовании не требуется обрабатывать нестандартные ситуации, возникающие в ходе доставки почты с использованием ненадежного соединения с Интернет. Обработчик протокола LMTP просто прослушивает порт, ожидая от используемого почтового сервера соединения и отправки байтового потока. После этого он преобразует этот байтовый поток в дерево объектов сообщения, создает начальный словарь метаданных и добавляет сообщение в обрабатываемую директорию очереди.

Система Mailman также использует обработчик, который прослушивает другой порт и обрабатывает REST-запросы, переданные по протоколу HTTP. Этот процесс вообще не затрагивает файлы очереди.

Стандартная работающая система Mailman может использовать восемь или десять процессов, причем все они должны быть остановлены и запущены корректно и согласованно. Они также могут внезапно аварийно завершаться; например, в случае, когда ошибка в Mailman приводит к неожиданному исключению. Когда это происходит доставляемое сообщение блокируется (shunted) и помещается в хранилище вместе с данными состояния системы в момент возникновения исключения, сохраненными в метаданных сообщения. Эта операция позволяет быть уверенным в том, что необработанное исключение не приведет к множеству повторных отправок сообщения. Теоретически администратор сайта, работающего под управлением системы Mailman, может устранить проблему, после чего разблокировать (unshunt) вызвавшие проблемы сообщения для повторной доставки с момента остановки процесса. После блокировки проблемного сообщения ведущий обработчик перезапускает аварийно завершивший работу обработчик очереди, который начинает обработку оставшихся сообщений из своей очереди.

При запуске ведущий обработчик исследует файл конфигурации для установления того, какое количество и какие типы дочерних обработчиков должны быть запущены. В случае обработчиков для поддержки протоколов LMTP и REST обычно используется по одному процессу. В случае обработчиков очередей, как было сказано выше, может быть запущено кратное двум количество параллельно выполняющихся процессов. Ведущий обработчик использует вызовы fork() и exec() для запуска всех обработчиков, необходимость которых заявлена в файле конфигурации, передавая соответствующие параметры командной строки каждому из них (т.е., указывая подпроцессу на то, с каким диапазоном хэш-значений ему следует работать). После этого ведущий обработчик блокируется в бесконечном цикле, ожидая завершения работы одного из своих дочерних процессов. Он отслеживает идентификаторы каждого из дочерних процессов вместе с количеством перезапусков каждого дочернего процесса. Этот подсчет позволяет избежать катастрофической ошибки, приводящей к непрерывному каскаду перезапусков. Существует переменная конфигурации, которая устанавливает разрешенное количество перезапусков, по истечении которого сообщение об ошибке заносится в системный журнал и обработчик больше не перезапускается.

При завершении работы дочернего процесса ведущий обработчик рассматривает код завершения процесса и сигнал, с помощью которого был завершен подпроцесс. Каждый процесс обработчика устанавливает функции обработки сигналов со следующими семантиками:

Ведущий обработчик также принимает четыре описанных выше сигнала, но не выполняет никаких действий, кроме рассылки их всем подпроцессам. Следовательно, если вы отправили сигнал SIGTERM ведущему обработчику, все подпроцессы получат сигнал SIGTERM и завершат работу. Ведущий обработчик получит информацию о том, что обработчик завершил работу после приема сигнала SIGTERM, а также о том, что это завершение работы было умышленным, поэтому он не перезапустит обработчик.

Для того, чтобы быть уверенным в том, что только один процесс ведущего обработчика выполняется в каждый момент времени, этот процесс устанавливает блокировку с временем существования около дня или половины дня. Ведущий обработчик устанавливает функцию обработки сигнала SIGALRM, которая позволяет раз в день обновлять блокировку. Так как время жизни блокировки превышает интервал ее обновления, время действия блокировки никогда не истечет и блокировка не станет неработоспособной во время функционирования системы Mailman, конечно же, за исключением случаев, когда система аварийно завершит работу или выполнение процесса ведущего обработчика будет завершено с использованием не обрабатываемого сигнала. Для этих случаев интерфейс командной строки процесса ведущего обработчика предусматривает параметр, позволяющий игнорировать блокировку с истекшим сроком действия.

Таким образом, мы переходим к заключительной части описания ведущего обработчика, а именно, описанию его интерфейса командной строки. Сам сценарий ведущего обработчика может принимать очень малое количество параметров командной строки. Этот сценарий, как и сценарии обработчиков очередей, умышленно упрощены. Это утверждение не было справедливым при рассмотрении версии 2 системы Mailman, в которой сценарий ведущего обработчика был достаточно сложен и пытался выполнять большое количество действий, что в большей степени затрудняло его понимание и отладку. В версии 3 системы Mailman реальный интерфейс командной строки для ведущего обработчика находится в сценарии bin/mailman, являющимся типом мета-сценария и содержащим множество подкоманд, оформленных в стиле, набравшем популярность вместе с такими программами, как Subversion. Это решение позволяет сократить количество программ, которые должны быть установлены в директорию, заданную переменной PATH вашей оболочки. В сценарии bin/mailman имеются подкоманды для запуска, остановки и перезапуска ведущего обработчика, также, как и всех его подопроцессов, а также команды для принудительного повторного открытия всех файлов журналов. Подкоманда start использует вызовы функций fork() и exec() процессом ведущего обработчика, в то время, как все остальные просто отправляют соответствующий сигнал ведущему серверу, который затем распространяет этот сигнал среди своих подпроцессов, как описано выше. Это улучшенное разделение ответственности упрощает понимание роли каждого отдельного программного компонента.

10.5. Правила, звенья и цепочки

Размещение сообщений в списке рассылки состоит из нескольких фаз, от момента первого приема сообщения до момента отправки сообщения участникам списка рассылки. В версии 2 системы Mailman каждый этап обработки сообщения был представлен обработчиком сообщения (handler), а наборы обработчиков объединялись в канал (pipeline). Следовательно, в тот момент, когда сообщение поступает в систему, Mailman в первую очередь установит, какой канал будет использован для его обработки, после чего каждый обработчик сообщения канала будет вызван по очереди. Некоторые обработчики сообщений выполняют функции модерации (т.е., функции, заключающиеся в ответе на вопрос: "Разрешено ли данному человеку размещать сообщения в списке рассылки?"), другие выполняют функции модификации сообщений (в данном случае вопрос звучит так: "Какие заголовки следует убрать или добавить?"), а другие - копируют сообщение в другие очереди. Несколько примеров использования обработчиков сообщений последнего типа:

Архитектура, использующая канал из обработчиков сообщений, продемонстрировала достаточную эффективность. Она предоставляет простой способ, который люди могут использовать для расширения и изменения возможностей системы Mailman с целью выполнения специфических операций. Интерфейс обработчика сообщений был достаточно простым, что было причиной для реализации новых обработчиков сообщений, при этом следовало просто убедиться в том, что он добавлен в необходимый канал в нужном месте для выполнения специфической операции.

Единственной проблемой данного подхода является тот факт, что смешивание функций модерации и модификации сообщений в рамках одного канала может привести к проблемам. Обработчики должны быть установлены в канале в определенной последовательности, иначе результаты обработки сообщений могут быть непредсказуемыми и нежелательными. Например, если обработчик сообщения добавит заголовки List-*, описанные стандартом RFC 2369, после того, как другой обработчик сообщения скопирует его в хранилище каталога, подписчикам, получающим каталоги, будут отправлены некорректные копии сообщений из списка рассылки. В различных случаях может оказаться выгодной модерация сообщения до или после его модификации. В версии 3 системы Mailman операции модерации и модификации были разделены и реализованы в рамках отдельных подсистем для лучшего контроля над последовательностью их выполнения.

Как было описано ранее, обработчик для поддержки протокола LMTP производит разбор входящего байтового потока, преобразует его в дерево объектов сообщения и создает начальный словарь метаданных сообщения. После этого он перемещает сообщения в одну из других директорий очередей. Некоторые сообщения могут быть email-командами (email commands) (т.е., командами для создания или удаления подписки на список рассылки, для получения автоматизированной справки, и.т.д.), обрабатываемыми в рамках отдельной очереди. Большинство сообщений являются сообщениями для размещения в списке рассылки, которые помещаются в очередь входящих сообщений (incoming queue). Обработчик очереди входящих сообщений обрабатывает каждое сообщение последовательно в цепочке (chain), состоящей из любого количества звеньев (links). Существует встроенная цепочка, которую использует большинство списков рассылки, но даже это возможно изменить путем изменения параметра конфигурации.

Рисунок 10.3 иллюстрирует стандартный набор цепочек версии 3 системы Mailman. Каждое звено в цепочке показано с помощью прямоугольника со скругленными краями. Встроенная цепочка отличается тем, что в ней в отношении входящих сообщений применяются начальные правила модерации, а также в этой цепочке каждое звено ассоциировано с правилом (rule). Правила являются простыми фрагментами кода, которым передается три стандартных параметра: объект списка рассылки, дерево объектов сообщения и словарь метаданных сообщения. Правила не предназначены для модификации сообщения; они просто принимают решение и возвращают логическое значение, отвечая на вопрос: "Выполняется ли правило или нет?". Правила также могут записывать информацию в словарь метаданных сообщения.

На рисунке сплошные линии со стрелками отображают направления потока сообщений в случае выполнения правила, а пунктирные линии со стрелками отображают направления потока сообщений в случае невыполнения правила. Результат проверки каждого правила записывается в словарь метаданных сообщения, поэтому впоследствии система Mailman будет располагать информацией (и сможет сформировать отчет) о том, какие конкретно правила были выполнены, а какие - нет. Штриховые линии со стрелками указывают на безусловные перемещения сообщений, не связанные с тем, выполнилось ли правило или нет.

Упрощенный вид стандартных цепочек с их звеньями
Рисунок 10.3: Упрощенный вид стандартных цепочек с их звеньями

Важно отметить, что сами правила не выполняют действий на основании результатов. Во встроенной цепочке каждое звено ассоциировано с действием (action), которое выполняется в случае выполнения правила. Например, когда выполняется правило "повтора" (определяющее, что это сообщение уже встречалось ранее в списке рассылки), сообщение немедленно перемещается в цепочку "отклоненных сообщений", из которой оно будет удалено по истечении некоторого периода хранения. Если правило "повтора" не выполняется, то следующее звено в цепочке будет обрабатывать это же сообщение.

На Рисунке 10.3 звенья, ассоциированные с правилами административной проверки ("administrivia"), проверки максимального размера ("max-size") и истинного решения ("truth") не возвращают логических результатов. В случае первых двух правил такая реализация объясняется тем, что их действие отсрочено, поэтому они просто записывают результат сравнения и обработка сообщения продолжается следующим звеном. После этого правило проверки любого значения ("any") проверяет, выполнено ли любое из предыдущих правил. Таким образом система Mailman может сообщить о всех причинах запрета на размещение сообщения, вместо единственной первой причины. Существует еще несколько таких правил, которые не были отображены на рисунке с целью упрощения представления.

Правило истинного решения ("truth") немного отличается. Оно обычно ассоциируется с последним звеном в цепочке и всегда выполняется. В комбинации с предпоследним в цепочке правилом проверки любого значения ("any"), затрагивающим результаты выполнения правил в отношении всех ранее обработанных сообщений, последнее звено может быть уверенно в том, что любое достигшее его сообщение должно быть размещено в списке рассылки, поэтому оно безусловно перемещается в цепочку разрешенных для размещения сообщений ("accepted" chain).

Существует несколько других особенностей процесса обработки цепочек, не описанных в данной главе, но архитектура этой части системы является очень гибкой и расширяемой, поэтому в ее рамках может быть реализован практически любой метод обработки сообщений и администраторы сайтов могут изменять и расширять набор функций правил, звеньев и цепочек.

Что случится с сообщением после попадания в цепочку разрешенных для размещения сообщений ("accept" chain)? Сообщение, которое теперь считается подходящим для списка рассылки направляется в очередь канала (pipeline queue) для выполнения некоторых модификаций перед доставкой принимающим сообщения подписчикам списка рассылки. Этот процесс более подробно описан в следующем разделе.

Цепочка хранения сообщений ("hold" chain) помещает сообщение в специальное хранилище для ознакомления с ним человека, выполняющего функции модератора. Цепочка модерации ("moderation" chain) выполняет небольшое количество дополнительных операций для формирования решения по поводу того, должно ли сообщение быть принято, заблокировано для утверждения модератором, не принято или отклонено. Для того, чтобы избежать чрезмерного усложнения диаграммы, очередь отклоненных сообщений, которая используется для возвращения сообщений отправителям, не отображена.

10.6. Обработчики сообщений и каналы

Как только сообщение проходит через все звенья и преодолевает все проверки выполнения правил, получая разрешение на размещение в списке рассылки, оно должно быть дополнительно обработано перед тем, как будет доставлено конечным адресатам. Например, некоторые заголовки могут быть добавлены или удалены, а также некоторые сообщения могут подвергнуться дополнительным модификациям для предоставления важных предупреждений или информации, такой, как информация том, как покинуть список рассылки. Эти модификации выполняются в рамках канала, содержащего последовательность обработчиков сообщений. Аналогично обработчикам сообщений в звеньях при использовании правил, функции каналов и обработчиков сообщений также могут быть расширены, но существует набор встроенных каналов, предназначенных для стандартного использования. Обработчики сообщений имеют такой же интерфейс, как и правила, принимающий объект списка рассылки, объект сообщения и словарь метаданных. Однако, в отличие от правил, обработчики сообщений могут дополнять и модифицировать сообщение. Рисунок 10.4 иллюстрирует стандартный канал и набор обработчиков сообщений (некоторые обработчики сообщений исключены для упрощения).

Очередь обработчиков сообщения в рамках канала
Рисунок 10.4: Очередь обработчиков сообщения в рамках канала

Например, отправленное сообщение должно иметь добавленный заголовок Precedence:, который сообщает другим автоматически функционирующим программным компонентам о том, что сообщение пришло из списка рассылки. Этот заголовок является стандартом де-факто, позволяющим предотвратить отправку ответов на сообщения из списка рассылки при использовании автоматизированных программ. Добавление этого заголовка (наряду с другими модификациями заголовков) осуществляется с помощью обработчика сообщений для добавления заголовков ("add header" handler). В отличие от правил, порядок использования обработчиков сообщений в общем случае не существенен и сообщения всегда проходят через все обработчики сообщений канала.

Некоторые обработчики сообщений отправляют копии сообщения в другие очереди. Как показано на Рисунке 10.4, существует обработчик сообщения, создающий копию сообщения для подписчиков, которые хотят принимать каталоги сообщений. Копии также отправляются в очередь архива для последующего помещения в архив списков рассылки. Наконец, сообщение копируется в очередь исходящих сообщений для окончательной доставки подписчикам списка рассылки.

10.7. VERP

Аббревиатура VERP расшифровывается как Variable Envelope Return Path и используется для обозначения широко известной техники, используемой списками рассылок для однозначного определения реального адреса принимающей стороны. Когда адрес в списке рассылки перестает быть активным, почтовый сервер принимающей стороны отправит уведомление об этом отправителю сообщения. В случае списки рассылки вам потребуется сделать так, чтобы это уведомление было отправлено в список рассылки, а не автору оригинального сообщения; автор не сможет сделать ничего с уведомлением и хуже того, отправка сообщения назад автору может раскрыть информацию о том, кто подписан на список рассылки. Когда список рассылки получает уведомление, однако, он может выполнить какое-либо полезное действие, такое, как отключение адреса из уведомления или удаление подписки на список рассылки, использующей этот адрес.

При этом существуют две основные проблемы. Во-первых, даже с учетом того, что существует стандартный формат описанных выше уведомлений (называемый delivery status notifications), многие используемые серверы электронной почты не соблюдают его. Вместо этого основная часть их уведомлений может содержать любой объем сложно разбираемого программно текста, что затрудняет их автоматическую обработку. Фактически система Mailman использует библиотеку, содержащую множество данных для эвристического анализа уведомлений, сформированную из данных реально принятых уведомлений за период 15 лет существования Mailman.

Во-вторых, представим ситуацию, при которой участник списка рассылки использует несколько перенаправлений сообщений. Участница может быть подписана на список рассылки с использованием ее адреса anne@example.com, но сообщения с него могут перенаправляться на адрес person@example.org, с которого сообщения также могут перенаправляться на адрес me@example.net. Когда последний сервер example.net получает сообщение, он обычно просто отправляет сообщение, указывающее на то, что адрес me@example.net более не действителен. Но сервер Mailman, отправивший сообщение, знает только об адресе участника anne@example.com, поэтому уведомление о недействительном адресе me@example.net не будет содержать адреса подписчика и Mailman проигнорирует его.

В данном случае используется техника VERP, которая эксплуатирует фундаментальное требование к протоколу SMTP о предоставлении возможности однозначного определения адреса назначения путем возвращения таких уведомлений отправителю сообщения (envelope sender). Эта операция осуществляется не с использованием поля From: сообщения, а фактически с использованием значения MAIL FROM, устанавливаемого во время диалога SMTP. Эта информация сохраняется в ходе доставки сообщения и конечный почтовый сервер должен, в соответствии со стандартами, отправлять уведомления на полученный адрес. Система Mailman использует этот факт для кодирования оригинального адреса назначения в качестве значения MAIL FROM.

Если сервер Mailman использует адрес mylist@example.org, то закодированный адрес отправителя сообщения для размещения в списке рассылки с помощью технологии VERP, отправляемый на адрес anne@example.com, будет следующим:

mylist-bounce+anne=example.com@example.org

В данном случае символ + используется для отделения локального адреса, причем это форматирование поддерживается большинством современных почтовых серверов. Таким образом, когда сообщение возвратится, оно на самом деле будет доставлено на адрес mylist-bounce@example.com, но в заголовке To: будет находиться закодированный с использованием техники VERP адрес получателя. После этого система Mailman может произвести разбор этого заголовка To: для декодирования оригинального адреса назначения в виде anne@example.com.

Хотя техника VERP и является очень мощным инструментом для фильтрации некорректных адресов и недопущения их попадания в список рассылки, она имеет один потенциально важный недостаток. Использование техники VERP требует от системы Mailman отправки только одной копии сообщения для каждой принимающей стороны. Без техники VERP система Mailman могла отсылать наборы идентичных копий исходящих сообщений множеству принимающих сторон, экономя тем самым общую пропускную способность канала и время обработки сообщений. Но техника VERP требует использования уникального значения MAIL FROM для каждой принимающей стороны, а единственным способом удовлетворения этого требования является отправка уникальных копий сообщения. В общем случае это приемлемый компромисс и, фактически, вместе с отправкой этих индивидуальных сообщений для задействования техники VERP, система Mailman также сможет сделать множество полезных вещей в любом случае. Например, она может встраивать строку URL в заключительную часть сообщения, сформированную для каждого из подписчиков и позволяющую использовать прямую ссылку для закрытия подписки. Вы можете представить множество различных типов операций обработки сообщений с целью модификации текста сообщения для каждого индивидуального подписчика.

10.8. REST

Одно из ключевых архитектурных изменений в версии 3 системы Mailman заключается в удовлетворении запроса, предъявляемого к ней в течение многих лет: предоставления упрощенного способа интеграции Mailman со сторонними системами. Когда я был нанят компанией Canonical, являющейся корпоративным спонсором проекта Ubuntu в 2007 году, моя работа изначально заключалась в добавлении возможности использования списков рассылки в Launchpad, хостинг-платформу для совместной работы над программными проектами. Я знал, что версия 2 системы Mailman подходила для решения задачи, но при этом требовалось задействовать пользовательский веб-интерфейс системы Launchpad вместо стандартного пользовательского веб-интерфейса системы Mailman. Так как списки рассылки системы Launchpad практически всегда использовались в качестве дискуссионных списков рассылки, нам хотелось добиться сокращения различий в методах управления ими. Администраторы списков рассылки не должны иметь в распоряжении избыточное количество параметров конфигурации, доступных на стандартном сайте под управлением системы Mailman, поэтому оставалось дать ответ на вопрос о том, какие параметры могут понадобиться и будут представлены в пользовательском интерфейсе системы Launchpad.

В тот момент система Launchpad не являлась свободным программным обеспечением (это изменилось в 2009 году), поэтому нам пришлось проектировать уровень интеграции таким образом, чтобы код системы Mailman версии 2, распространявшийся в соответствиями с условиями лицензии GPLv2, не затрагивал код Launchpad. Это привело к выработке ряда архитектурных решений в ходе проектирования архитектуры уровня интеграции, которые были достаточно неочевидными и отчасти неэффективными. Так как на данный момент система Launchpad является свободным программным обеспечением, распространяемым в соответствии с условиями лицензии AGPLv3, эти решения на сегодняшний день не являются необходимыми, но работа над ними позволила получить полезные знания о том, как система Mailman без пользовательского интерфейса может быть интегрирована в состав сторонних систем. Стал очевидным тот факт, что основная часть системы, эффективно и надежно реализующая операции для работы со списком рассылки, может работать под управлением любого типа веб-интерфейса, включая интерфейсы на основе Zope, Django или PHP, а также вообще без пользовательского веб-интерфейса.

В тот момент для реализации этой идеи был доступен ряд технологий, при этом фактически интеграция систем Mailman и Launchpad основана на использовании протокола XMLRPC. Но протокол XMLRPC имеет ряд проблем, что делает его не идеальным протоколом.

В версии 3 системы Mailman была применена модель передачи состояния представления (Representational State Transfer - REST) для внешнего административного управления. Модель REST основана на протоколе HTTP и стандартном формате представления объектов системы Mailman, JSON. Эти протоколы используются повсеместно и отлично поддерживаются большинством разнообразных языков программирования и окружений, делая интеграцию Mailman со сторонними системами достаточно простой. Модель REST отлично подходила для использования совместно с версией 3 системы Mailman, а сейчас большинство функций системы доступно при использовании REST API.

Это мощная парадигма, которую должно использовать большее количество приложений: предоставлять основную часть, которая качественно реализует базовые функции, предоставляя REST API для управления ею. REST API предоставляет дополнительный способ интеграции с системой Mailman, в дополнение к использованию интерфейса командной строки или разработки кода на языке Python для доступа к внутреннему API. Эта архитектура является очень гибкой и может быть использована и интегрирована такими способами, которые находятся за гранью стандартного видения системных архитекторов.

Такая архитектура позволяет не только вести разработку большим количеством способов, но даже проектировать и реализовывать официальные компоненты системы. Например, новый официальный пользовательский веб-интерфейс в версии 3 системы Mailman технически является отдельным проектом со своей кодовой базой, развиваемым в основном опытными веб-дизайнерами. Эти выдающиеся разработчики имеют возможность принимать решения, изменять дизайн и запускать реализации веб-интерфейса, не испытывая затруднений из-за хода разработки основного кода системы. В ходе разработки пользовательского веб-интерфейса отправляются запросы добавления необходимых функций в основную часть кода и разрешения доступа к ним с использованием REST API, но разработчикам веб-интерфейса не нужно ждать реализации необходимых функций, так как они могут использовать прототип сервера и продолжать экспериментировать и разрабатывать пользовательский веб-интерфейс в то время, как будет дорабатываться основная часть кода.

Мы планируем использовать REST API для множества других вещей, включая возможность добавления стандартных операций в сценарии и интеграцию с серверами IMAP или NNTP для предоставления альтернативной возможности доступа к архивам.

10.9. Интернационализация

Приложение GNU Mailman было одним из первых приложений на языке Python, поддерживающим функции интернационализации. Конечно же, так как система Mailman не всегда модифицирует содержимое проходящих через нее сообщений электронной почты, эти сообщения могут использовать любой язык в соответствии с выбором автора. Однако, при прямом взаимодействии с Mailman либо с использованием веб-интерфейса, либо с использованием команд из сообщений электронной почты, пользователи предпочтут использовать их родной язык.

Система Mailman впервые использовала множество технологий интернационализации, предлагаемых языком Python, но более запутанным способом, чем большинство приложений. В стандартном окружении рабочего стола язык выбирается при входе пользователя в систему и остается неизменным в течение сессии. Однако, Mailman является серверным приложением, поэтому оно должно иметь возможность работать с множеством языков вне зависимости от от языка, с которым работает система. Фактически система Mailman должна определять контекст языка (language context), с учетом которого должен отправляться ответ, и переводить свой текст на этот язык. Иногда для формирования ответа может потребоваться использовать множество языков; например, если уведомление от пользователя из Японии должно быть перенаправлено в список рассылки администраторов, которые разговаривают по-немецки, по-итальянски и по-каталонски.

Повторю, что система Mailman впервые использовала ключевые технологии языка Python, предназначенные для обработки таких сложных контекстов языка, как эти. Она использует библиотеку, управляющую стеком языков, которые могут быть добавлены или извлечены в форме изменений контекста, даже в случае обработки единственного сообщения. Также реализуется продуманная схема изменения шаблонов ответов на основе настроек сайта, настроек владельца списка рассылки и выбранного языка. Например, если владелец списка рассылки хочет использовать заданный шаблон ответа для одной из рассылок, но только для пользователей из Японии, он может разместить определенный шаблон в определенном месте файловой системы, после чего более общие настройки системы не будут учитываться.

10.10. Выученные уроки

Хотя в данной главе и представлен обзор архитектуры версии 3 системы Mailman, а также подробный обзор процесса эволюции этой архитектуры в течение 15 лет существования программного продукта (после трех значительных переработок кода), существует много интересных архитектурных решений, примененных в рамках Mailman, которые мне не удалось затронуть. Они включают в себя подсистему конфигурации, инфраструктуру тестирования, уровень для работы с базой данных, программное использование формальных интерфейсов, архивирование, стили списков рассылки, команды из сообщений электронной почты и интерфейс командной строки, а также вопросы интеграции с используемым сервером электронной почты. Свяжитесь с нами с помощью списка рассылки разработчиков системы Mailman в том случае, если вам интересны эти вопросы и вы хотите получить более подробное описание.

Ниже приведен список уроков, усвоенных нами в ходе переработки кода популярной, признанной и стабильной части экосистемы приложений с открытым исходным кодом.

GNU Mailman является развивающимся проектом со здоровой пользовательской базой и огромным количеством возможностей для участия в проекте. Ниже представлены ресурсы, которые вы можете использовать в том случае, если вам захочется помочь нам с разработкой и я надеюсь, что именно это вы и сделаете!

Заключительные слова

Во время написания этой главы мы с глубокой скорбью узнали о кончине Tokio Kikuchi (http://wiki.list.org/display/COM/TokioKikuchi), профессора из Японии, который внес большой вклад в разработку Mailman и обладал исключительными знаниями в области вопросов интернационализации и характеристик японских программ для чтения сообщений электронной почты. Нам будет очень не хватать его.

11.1. Проблема ключа аппаратной защиты

История создания библиотеки matplotlib берет свое начало с попытки одного из нас (John Hunter) избавить себя и своих исследующих эпилепсию коллег от использования пропиетарного программного пакета, предназначенного для анализа электроэнцефалограмм (EcoG). Лаборатория, в которой он работал, обладала только одной лицензией на использование программного обеспечения, поэтому различные выпускники и студенты медицинских университетов, научные сотрудники с учеными степенями, интерны и исследователи по очереди использовали ключ аппаратной защиты программного обеспечения. Система MATLAB широко использовалась в рамках сообщества биомедиков для анализа и визуализации данных, поэтому John Hunter с некоторым успехом смог заменить пропиетарное программное обеспечение на версию программного обеспечения на основе системы MATLAB, которая должна была впоследствии использоваться и совершенствоваться многими исследователями. Однако, система MATLAB рассматривает мир как массив чисел с плавающей точкой и сложность реальных медицинских записей состояния больных эпилепсией пациентов с множеством данных условий (CT, MRI, ECoG, EEG) обусловила хранение данных на различных серверах ввиду исчерпания возможностей системы MATLAB в качестве системы управления данными. Убедившись в непригодности системы MATLAB для выполнения поставленной задачи, John Hunter начал работу над новым приложением на языке Pyhon с пользовательским интерфейсом на основе тулкита GTK+, на основе которого также была построена ведущая на тот момент оконная система для Linux.

Таким образом, библиотека matplotlib изначально разрабатывалась как инструмент для визуализации данных EEG/ECoG для этого приложения на основе GTK+, а условия ее использования были продиктованы оригинальной архитектурой. Изначально библиотека matplotlib была спроектирована также с другой целью: выступить заменой инструмента для интерактивной генерации графиков на основе команд, что системе MATLAB удавалось очень хорошо. Архитектура системы MATLAB позволяла выполнять простую задачу загрузки данных из файла и построения на основе этих данных графика достаточно прозрачно, при этом использование полностью объектно-ориентированного API в данном случае привело бы к значительному усложнению синтаксиса. Поэтому библиотека matplotlib также предоставляет не зависящий от состояния интерфейс сценариев для быстрой и простой генерации графиков, аналогично тому, как это реализуется в системе MATLAB. Так как matplotlib является библиотекой, пользователи имеют доступ ко всему множеству встроенных структур данных языка Python, таких, как списки, словари, множества и другим.

Оригинальное использующее matplotlib приложение: инструмент для визуализации данных ECoG
Рисунок 11.1: Оригинальное использующее matplotlib приложение: инструмент для визуализации данных ECoG

11.2. Обзор архитектуры библиотеки matplotlib

Объект библиотеки, находящийся на верхнем уровне и содержащий и управляющий всеми элементами заданного графика, носит имя Figure. Одной из основных архитектурных задач, которую должна решать библиотека matplotlib, является реализация фреймворка для представления и манипуляции объектом Figure независимо от операции отображения объекта Figure в окне пользовательского интерфейса или файле. Это позволяет нам интегрировать усложненные функции и логические операции в объекты Figure, делая системы поддержки вывода данных или системы поддержки устройств вывода относительно простыми. Библиотека matplotlib реализует не только интерфейсы рисования для предоставления возможности вывода данных на множество устройств, но также базовые функции обработки событий и создания окон для большинства популярных тулкитов, используемых для построения пользовательских интерфейсов. Благодаря этому пользователи могут создавать довольно сложные интерактивные графики и тулкиты, поддерживающие ввод с использованием клавиатуры и мыши, которые могут быть подключены без модификаций к шести тулкитам для создания пользовательских интерфейсов, поддерживаемым нами.

Архитектура для реализации этих возможностей логически разделена на три уровня, которые могут рассматриваться в виде стека. При этом каждый уровень, находящийся над другим уровнем, знает метод обращения к нижележащему уровню, но нижележащий уровень не располагает информацией об уровнях, находящихся выше него. Три уровня снизу вверх: уровень вывода данных, уровень рисования, уровень сценариев.

Уровень вывода данных

Снизу стека расположен уровень вывода данных (backend layer), который предоставляет реализации абстрактных классов интерфейса:

Для такого тулкита пользовательского интерфейса, как Qt, класс FigureCanvas содержит реализацию алгоритма, позволяющего произвести встраивание в созданное с помощью Qt окно (QtGui.QMainWindow), передать команды класса Renderer библиотеки matplotlib классу канвы (QtGui.QPainter) и преобразовать события тулкита Qt в события класса Event библиотеки matplotlib, который отправляет сигналы обработчику обратных вызовов функций для генерации событий, чтобы высокоуровневые обработчики смогли использовать их. Базовые абстрактные классы располагаются в модуле matplotlib.backend_bases, а все дочерние классы расположены в таких отдельных модулях, как matplotlib.backends.backend_qt4agg. В случае систем вывода данных в файлы изображений, таких, как PDF, PNG, SVG или PS, реализация класса FigureCanvas может просто инициализировать объект типа файла с описаниями стандартных заголовков, шрифтов и макро-функций наряду с отдельными объектами (линиями, текстом, прямоугольниками, и.т.д.), создаваемыми с помощью класса Renderer.

Задачей класса Renderer является предоставление низкоуровневого интерфейса рисования для изображения фигур на канве. Как было сказано выше, оригинальное приложение на основе библиотеки matplotlib являлось инструментом для визуализации данных ECoG на основе тулкита GTK+ и большая часть оригинальной архитектуры была создана под влиянием API GDK/GTK+, доступного в тот момент. Оригинальный API класса Renderer был создан на основе интерфейса Drawable GDK, который реализует такие примитивные методы, как draw_point, draw_line, draw_rectangle, draw_image, draw_polygon и draw_glyphs. В каждой из разрабатываемых нами систем вывода данных - первыми были системы вывода данных в файлы формата PostScript и с помощью библиотеки GD - был реализован API GDK Drawable, после чего его методы преобразовывались в используемые данной системой команды рисования. Как мы обсудили выше, эта необоснованно запутанная реализация новых систем вывода данных с большим количеством методов, а также этот API впоследствии были значительно упрощены, что привело к упрощению процесса портирования библиотеки matplotlib для использования новых тулкитов пользовательского интерфейса или спецификаций файлов.

Одним из удачно функционирующих архитектурных решений в рамках библиотеки matplotlib является поддержка низкоуровневой библиотеки вывода, использующей библиотеку шаблонов языка C++ с названием Anti-Grain Geometry или "agg" [She06]. Это высокопроизводительная библиотека для вывода 2D-графики со сглаживанием (anti-aliasing), которая позволяет создавать привлекательные изображения. Библиотека matplotlib поддерживает вставку буферов пикселей, выводимых библиотекой agg в качестве элемента пользовательского интерфейса на основе каждого из поддерживаемых нами тулкитов, поэтому становится возможным вывод идентичных с точностью до пикселя графиков в различных пользовательских интерфейсах и операционных системах. Так как при выводе с помощью matplotlib изображений в формате PNG также используется библиотека agg, изображение из файла будет идентичным изображению на экране, поэтому вы увидите график, который не изменится в различных пользовательских интерфейсах, операционных системах и файлах формата PNG.

Фреймворк Event из состава matplotlib связывает такие события уровня пользовательского интерфейса, как key-press-event или mouse-motion-event с классами KeyEvent или MouseEvent из состава matplotlib. Пользователи могут соединить эти события с функциями обратного вызова и осуществлять взаимодействие с их графиками и данными; например, для захвата элемента или множества элементов набора данных (pick) или манипуляции каким-либо аспектом отображения графика или его составных частей. Следующий пример кода иллюстрирует метод переключения отображения всех линий в использующем класс Axes окне при нажатии пользователем клавиши 't'.

Абстракция над фреймворком событий уровня тулкита для создания пользовательского интерфейса позволяет и разработчикам библиотеки matplotlib, и конечным пользователям осуществлять обработку событий пользовательского интерфейса в соответствии с подходом "разработать один раз и использовать везде". Например, функции интерактивной фиксации и масштабирования созданных с использованием библиотеки matplotlib изображений, работающие со всеми тулкитами пользовательского интерфейса, реализуются в рамках фреймворка обработки событий библиотеки matplotlib.

Уровень рисования

Иерархия классов уровня рисования (Artist hierarchy) находится на среднем уровне стека, а также является местом, где производится большая часть сложных операций. Продолжая аналогию, по которой класс FigureCanvas системы вывода данных является бумагой, класс Artist является объектом, который знает, как использовать класс Renderer (кисть) и поместить краску на канву. Все рисунки, принадлежащие классу Figure, которые вы видите, состоят из экземпляров класса Artist; заголовок, линии, метки на осях, изображения и другие элементы соответствуют отдельным экземплярам класса Artist (обратитесь к Рисунку 11.3). Базовым классом является класс matplotlib.artist.Artist, который содержит атрибуты, свойственные каждому классу Artist: данные преобразования, используемые для преобразования координат класса рисования в координаты канвы (этот процесс описан ниже в подробностях), настройки видимости, координаты области, устанавливающие регион, в котором может осуществляться рисование, строку с названием и интерфейс для осуществления взаимодействия с пользователем, например, для "выбора точек"; это взаимодействие осуществляется путем установления факта нажатия кнопки мыши в области рисования.

Готовый рисунок
Рисунок 11.2: Готовый рисунок

Иерархия экземпляров класса уровня рисования, используемая при создании Рисунка 11.2.
Рисунок 11.3: Иерархия экземпляров класса уровня рисования, используемая при создании Рисунка 11.2.

Объединение иерархии классов уровня рисования (Artist hierarchy) и системы вывода данных осуществляется в рамках метода draw. Например, в шаблоне класса, приведенном ниже, где мы создали класс SomeArtist, являющийся подклассом класса Artist, основным методом, который должен был быть реализован в рамках класса SomeArtist, является метод draw, с помощью которого примитив для рисования передается от системы вывода данных. Класс Artist не располагает информацией о том, какая система вывода данных будет использоваться для рисования (PDF, SVG, GTK+ DrawingArea, и.т.д.), но при этом он располагает информацией о том, как работать с API класса Renderer и будет использовать подходящий метод (draw_text или draw_path). Так как класс Renderer содержит указатель на канву и располагает информацией о том, как рисовать на ней, метод draw осуществляет преобразование абстрактного представления иерархии классов Artist в цвета буфера пикселей, координаты направлений в файле SVG или любое другое конкретное представление.

Существует два типа классов Artist в рамках иерархии классов уровня рисования. Примитивные классы (Primitive artists) представляют типы объектов, которые вы видите на графике: Line2D (двумерная линия), Rectangle (прямоугольник), Circle (окружность) и Text (текст). Композитные объекты (Composite artists) являются наборами классов Artist, такими, как классы Axis (ось), Tick (метки), Axes (координатные оси) и Figure (рисунок). Каждый композитный класс может содержать другие композитные классы также, как и примитивные классы. Например, класс Figure содержит один или несколько композитных классов Axes и фон изображения, представленного классом Figure, создан с помощью примитивного класса Rectangle.

Наиболее важным композитным классом является класс Axes, в рамках которого объявлена большая часть методов для создания графиков из состава API библиотеки matplotlib. Класс Axes содержит не только большую часть графических элементов, формирующих фон графика - метки, линии координатные оси, сетку, цвет, используемый для фона графика, но и множество вспомогательных методов для создания примитивных классов и добавления их в экземпляр класса Axes. Например, в Таблице 11.1 показан пример нескольких методов класса Axes, с помощью которых объекты графика создаются и сохраняются в экземпляре класса Axes.

Таблица 11.1: Пример методов класса Axes и экземпляров класса Artist, которые создаются с помощью них

Метод Создает класс Хранится в
Axes.imshow Один или несколько экземпляров matplotlib.image.AxesImage Axes.images
Axes.hist Множество экземпляров matplotlib.patch.Rectangle Axes.patches
Axes.plot Один или несколько экземпляров matplotlib.lines.Line2D Axes.lines

Ниже приведен простой сценарий на языке Python, иллюстрирующий описанные выше архитектурные решения. Он устанавливает систему вывода данных, соединяет с ней экземпляр класса Figure, использует библиотеку для работы с массивами numpy для генерации 10000 нормально распределенных случайных чисел и формирует их гистограмму.

Уровень сценариев (pyplot)

Приведенный выше сценарий, использующий API, работает очень хорошо, особенно в случае его использования разработчиками и примененная программная парадигма подходит для разработки серверных веб-приложений, приложений с пользовательским интерфейсом, или, возможно, для разработки сценариев и обмена ним со сторонними разработчиками. Для каждодневной работы, особенно в случае интерактивной исследовательской работы ставящих опыты ученых, которые не являются профессиональными программистами, синтаксис является в некоторой степени усложненным. Большинство специализированных языков программирования для анализа данных и их визуализации предоставляет простой интерфейс сценариев для упрощения выполнения стандартных задач, при этом библиотека matplotlib предоставляет интерфейс matplotlib.pyplot для выполнения аналогичных действий. Код, представленный выше, будет выглядеть следующим образом в случае использования pyplot:

Гистограмма, созданная с помощью pyplot
Рисунок 11.4: Гистограмма, созданная с помощью pyplot

Интерфейс pyplot является интерфейсом, изменяющим параметры состояния и выполняющим команды создания графиков с осями координат с последующим соединением их с выбранной системой вывода данных, а также поддерживающим внутренние структуры данных уровня модулей, представляющие текущий рисунок и координатные оси, в отношении которых непосредственно могут выполняться команды.

Давайте рассмотрим важные строки сценария для того, чтобы понять принцип управления внутренними данными состояния.

Незначительно сокращенная и упрощенная версия часто используемой функции для создания линий matplotlib.pyplot.plot интерфейса pyplot показана ниже для иллюстрации метода реализации функциональности объектно-ориентированного основного интерфейса matplotlib с помощью функции интерфейса pyplot. Другие функции интерфейса сценариев pyplot используют аналогичные архитектурные решения:

Директива языка Python @autogen_docstring(Axes.plot) извлекает строку документации для соответствующего метода API и добавляет ее корректно отформатированную версию в метод pyplot.plot; у нас имеется отдельный модуль matplotlib.docstring для этой магии со строками документации. Аргументы *args и **kwargs в документации используют специальные соглашения языка Python для указания всех аргументов и ключевых слов, относящихся к аргументам, предназначенных для передачи в качестве данных метода. Это позволяет нам перенаправить их соответствующему методу из состава API. Вызов ax = gca() позволяет использовать механизм изменения параметров состояния для получения "текущих экземпляров класса Axes" (каждый интерпретатор языка Python может иметь только один "текущий экземпляр класса Axes"), а также позволяет создать экземпляры классов Figure и Axes в случае необходимости. Вызов ret = ax.plot(*args, **kwargs) перенаправляет аргументы соответствующему методу экземпляра класса Axes и сохраняет возвращаемое значение для последующего возврата. Таким образом, интерфейс pyplot является достаточно тонкой оберткой над API основного класса Artist, при создании которой была предпринята попытка избежать копирования кода настолько, насколько это возможно путем раскрытия функций API, спецификаций вызова и строк документации в рамках интерфейса сценариев с минимальным количеством лишнего кода.

11.3. Рефакторинг системы вывода данных

Со временем количество методов API для рисования системы вывода данных неуклонно росло, при этом использовались следующие методы:

draw_arc, draw_image, draw_line_collection, draw_line, draw_lines, draw_point,
draw_quad_mesh, draw_polygon_collection, draw_polygon, draw_rectangle,
draw_regpoly_collection

К сожалению, использование большого количества методов системы вывода данных усложняло разработку новых систем вывода, а так как новые функции добавлялись в основной код, обновление существующих систем вывода данных также стало достаточно сложной задачей. Так как каждая из систем вывода данных была реализована силами одного разработчика, который являлся экспертом в области определенного формата файлов, иногда требовалось большое количество времени для реализации новой возможности в каждой из систем вывода данных, что еще больше затрудняло понимание пользователем того, какие функции доступны в той или иной системе.

В версии 0.98 библиотеки matplotlib код систем вывода данных подвергся рефакторингу с целью переноса всех функций из систем вывода данных, за исключением необходимых, в основной код библиотеки таким образом, чтобы в составе систем вывода данных остался минимум необходимой функциональности. Количество требуемых методов в рамках API систем вывода данных было значительно сокращено до следующих методов:

Возможно реализовать все необходимые функции рисования в рамках новой системы вывода данных, используя только перечисленные методы. (Мы можем пойти еще дальше и выводить текст с помощью метода draw_path, исключив тем самым необходимость в реализации метода draw_text, но мы не захотели реализовывать данное упрощение. Конечно же, в рамках системы вывода данных возможна реализация специфического метода draw_text для вывода "реального" текста.) Эти изменения упростили процесс создания и разработки новой системы вывода. Однако, в некоторых случаях системе вывода данных может потребоваться изменить принцип работы основного кода библиотеки для увеличения производительности операции вывода. Например, при рисовании маркеров (небольших символов, используемых для указания на вершины графика) для большей экономии места удобно однократно записывать изображение маркера в файл, после чего использовать его в необходимых местах, копируя методом "штампа". В этом случае система вывода данных может реализовать метод draw_markers. Если этот метод реализован, система вывода данных будет однократно записывать изображение маркера, после чего будет отправлять гораздо более короткую команду для повторного использования этого изображения во множестве точек. Если же этот метод не реализован, основной код библиотеки просто будет многократно выводить изображение маркера с помощью метода draw_path.

Полный список дополнительных методов API системы вывода данных:

11.4. Преобразования

Библиотека matplotlib тратит большое количество времени на операции преобразования координат из одной системы в другую. Во множество этих системы координат входят:

Каждый класс Artist имеет узел преобразования, содержащий данные о том, как произвести преобразование из одной системы координат в другую. Эти узлы преобразования объединены друг с другом в рамках ориентированного графа, в котором каждый узел зависит от родительского узла. По пути от ребер к корню графа, координаты пространства данных для любой вершины могут быть преобразованы в координаты результирующего файла. Большинство преобразований также является обратимым. Это обстоятельство позволяет выбрать элемент графика и получить его координаты пространства данных. Граф преобразований устанавливает зависимости между вершинами: при изменении данных преобразования для корня графа, таком, как изменение границ координатных осей в классе Axes, все данные преобразований, связанные с классом Axes становятся недействительными, так как точки должны быть перерисованы. Данные преобразований, связанные с другими классами Axes для рисунка, конечно же, не должны затрагиваться с целью предотвращения ненужных повторных расчетов и повышения интерактивности операций.

Узлы преобразований могут выполнять как простые афинные, так и неафинные преобразования. Афинные преобразования являются семейством преобразований, сохраняющих прямыми линии и соотношения расстояний изображения, выполняя его вращение, преобразование, масштабирование и наклон. Двумерные афинные преобразования представляются с помощью матрицы афинного преобразования размерностью 3x3. Координаты точки после преобразования (x',y') вычисляются путем умножения матрицы с начальными координатами (x, y) на следующую матрицу:

Координаты двумерного пространства могут быть просто преобразованы путем умножения их на матрицу трансформации. Афинные трансформации также обладают полезным свойством, заключающимся в том, что они могут быть объединены с помощью матричного умножения. Это значит, что для выполнения серий афинных преобразований матрицы трансформации могут быть перемножены только один раз, после чего результирующая матрица может быть использована для преобразования координат. Фреймворк преобразования координат библиотеки matplotlib автоматически объединяет (замораживает) матрицы афинных преобразований для сокращения объема вычислений. Возможность использования быстрых афинных преобразований важна, так как с помощью них можно повысить производительность интерактивного перемещения и масштабирования изображения в окне графического интерфейса приложения.

Не являющиеся афинными преобразования в рамках библиотеки matplotlib используют функции языка Python, поэтому они являются действительно произвольными. В рамках основного кода библиотеки matplotlib неафинные преобразования используются для логарифмического масштабирования, создания графиков в полярных координатах и создания географических проекций (Рисунок 11.5). Эти неафинные преобразования могут свободно смешиваться с афинными в графе преобразования. Библиотека matplotlib автоматически упростит афинные преобразования и перейдет к использованию произвольных функций только для части неафинных преобразований.

Одни и те же данные на графиках, подвергнутые трем неафинным преобразованиям: к логарифмическим координатам, к полярным координатам и к координатам проекции Ламберта
Рисунок 11.5: Одни и те же данные на графиках, подвергнутые трем неафинным преобразованиям: к логарифмическим координатам, к полярным координатам и к координатам проекции Ламберта

Используя эти простые операции, библиотека matplotlib может выполнять некоторые достаточно сложные задачи. Смешанное преобразование выполняется специальным узлом преобразования и предназначено для выполнения одного преобразования для оси x и другого преобразования для оси y. Это, конечно же, становится возможным только в случае, если рассматриваемые преобразования являются "разделяемыми", что подразумевает независимость координат x и y, ну а сами преобразования могут быть как афинными, так и неафинными. Эта возможность используется, например, для создания графиков в логарифмической системе координат, где одна или обе оси x и y могут использовать логарифмическую систему координат. Возможность использования смешанного преобразования позволяет совмещать доступные системы координат произвольным образом. Другой возможностью графа преобразования является разделение осей. Существует возможность "связать" ограничения одного графика с другим и быть уверенным в том, что при перемещении или масштабировании одного из графиков, состояние другого графика будет изменено соответствующим образом. В этом случае один и тот же узел преобразования просто совместно используется двумя осями, которые могут относиться даже к разным изображениям. На Рисунке 11.6 показан пример графа преобразования с задействованием некоторых из этих дополнительных возможностей. Ось axes1 является осью x в логарифмической системе координат; оси axis1 и axis2 совместно используют одну и ту же ось y.

Пример графа преобразования
Рисунок 11.6: Пример графа преобразования

11.5. Процесс обработки полилиний

При создании графика с помощью линий библиотека matplotlib выполняет ряд шагов для преобразования необработанных данных в линию на экране. В ранних версиях matplotlib эти шаги были взаимосвязаны. С тех пор код подвергся рефакторингу, поэтому они стали отдельными шагами процесса "преобразования путей". Это обстоятельство позволяет каждой системе вывода данных выбирать шаги процесса обработки для непосредственного выполнения, так как некоторые шаги полезны только в определенных контекстах.

11.6. Математические выражения

Так как пользователями библиотеки matplotlib обычно являются ученые, очень полезно иметь возможность вывода отформатированного текста, содержащего математические выражения, прямо на графике. Скорее всего, самым широко используемым синтаксисом для формирования математических выражений является синтаксис, используемый созданной Donald Knuth системой TeX. Он позволяет использовать входные данные в форме обычной текстовой строки, подобной следующей:

\sqrt{\frac{\delta x}{\delta y}}

и формировать на основе нее отформатированное математическое выражение.

Библиотека matplotlib предоставляет два варианта вывода математических выражений. Первый вариант, usetex, использует полную копию системы TeX на пользовательской машине для вывода математического выражения. Система TeX выводит данные о расположении символов и линий для формирования выражения в используемом формате DVI (независимом от устройства). После этого matplotlib разбирает этот файл DVI и преобразует его в набор команд рисования, с помощью которых одна из систем вывода данных сможет нанести выражение непосредственно на график. Этот подход позволяет обрабатывать самый запутанный синтаксис математических выражений. Однако, он требует от пользователя наличия полной установленной рабочей копии системы TeX. Поэтому библиотека содержит также внутреннюю систему вывода математических выражений, называемую mathtext.

Система mathtext является прямым портом системы вывода математических выражений из TeX, объединенным с более простой системой разбора текста, разработанной с использованием фреймворка для разбора текста pyparsing [McG07]. Этот порт был создан на основе опубликованной копии исходного кода TeX [Knu86]. Эта простая система разбора текста создает дерево из контейнеров (boxes) и связей (glue в терминологии TeX), которое после этого используется системой вывода данных. Хотя полная версия системы вывода математических выражений TeX и включается в комплект поставки, большой набор сторонних математических библиотек TeX и LaTeX из него исключается. Возможности этих библиотек переносятся в случае необходимости, с преимуществом для часто используемых и не являющихся специфичными для какой-либо области науки возможностей. Этот подход позволяет создать замечательный и не ресурсоемкий способ вывода математических выражений.

11.7. Тестирование с целью поиска регрессий

Исторически библиотека matplotlib не содержала большого количества низкоуровневых модульных тестов. Время от времени при получении сообщений о серьезной ошибке сценарий для ее воспроизведения добавлялся в специально предназначенную для таких файлов директорию дерева исходного кода. Отсутствие автоматизированных тестов приводило к обычным для такой ситуации проблемам и, что особенно важно, к регрессиям в ранее работающих функциях. (Нам скорее всего не следует внушать вам идею о том, что автоматизированное тестирование является полезной возможностью.) Конечно же, при наличии такого большого объема кода и множества параметров конфигурации, а также взаимозаменяемых частей кода (т.е., систем вывода данных), становится спорным утверждение о том, что для тестирования будет достаточно исключительно низкоуровневых модульных тестов; мы наоборот считаем, что наиболее эффективным является тестирование всех частей кода, функционирующих взаимосвязанно.

С этой целью был разработан сценарий, генерирующий множество графиков, при построении которых используются различные функции библиотеки matplotlib, в особенности те, которые было достаточно сложно реализовать. Этот подход немного облегчил процесс установления того, что новое изменение привело к непреднамеренному нарушению работы функции приложения, но корректность изображений все еще приходилось проверять вручную. Так как данное тестирование требовало большого количества ручной работы, оно не производилось достаточно часто.

На втором этапе этот метод был автоматизирован. Используемый на данный момент сценарий тестирования библиотеки matplotlib генерирует множество графиков, но вместо требования ручной обработки, эти графики автоматически сравниваются с образцами изображений. Все тесты используют фреймворк тестирования nose, который упрощает генерацию отчетов о непройденных тестах.

Усложняющим работу обстоятельством является тот факт, что сравнение изображений не может быть точным. Незначительные изменения версий библиотеки вывода шрифтов Freetype могут быть причиной незначительных отличий в выводе текста на различных машинах. Этих отличий не достаточно для того, чтобы считать график "некорректным", но достаточно, чтобы обнаружить различие в ходе побитового сравнения. Вместо этого фреймворк тестирования создает гистограммы обоих изображений и рассчитывает на их основе среднеквадратичное отклонение. Если это отклонение превышает заданное пороговое значение, считается что на изображениях слишком много различий и тест завершается неудачей. При неудачах в ходе проведения тестов генерируются изображения отличий, которые указывают на то, где произошли изменения графика (обратитесь к Рисунку 11.9). После этого разработчик может решить, является ли это различие результатом намеренного изменения и обновить образец графика для соответствия новому варианту изображения, или изображение на самом деле не является корректным, что подразумевает поиск и исправление ошибки, вызвавшей изменение.

Сравнение изображений в ходе тестирования с целью поиска регрессий. Слева направо: a) Ожидаемое изображение, b) результат нарушения расположения легенды, c) различие между двумя изображениями.
Рисунок 11.9: Сравнение изображений в ходе тестирования с целью поиска регрессий. Слева направо: a) Ожидаемое изображение, b) результат нарушения расположения легенды, c) различие между двумя изображениями.

Так как различные системы вывода данных могут содержать различные ошибки, фреймворк тестирования должен использовать множество систем вывода данных для каждого из графиков: PNG, PDF и SVG. В случае использования векторных форматов мы не сравниваем напрямую информацию из файлов, так как существует множество способов для отображения чего-либо с одинаковым результатом после преобразования в растровый формат. Системы вывода данных в векторный формат должны иметь полную свободу в плане изменения специфики своего вывода с целью повышения производительности без нарушения выполнения всех тестов. Следовательно, в случае систем для вывода данных в векторные форматы фреймворк тестирования в первую очередь преобразует файл в растровый формат с помощью стороннего инструмента (Ghostscript для PDF и Inkscape для SVG), после чего файл в растровом формате используется для сравнения.

Используя этот подход, нам удалось создать достаточно производительный фреймворк тестирования с нуля, причем это оказалось проще, чем разработка множества низкоуровневых модульных тестов. Все же, этот фреймворк не является идеальным; покрытие кода тестами не является полным, а также тратится большое количество времени для выполнения всех тестов. (Около 15 минут на системе с центральным процессором Intel Core 2 E6550 с тактовой частотой 2.23GHz.) Следовательно, некоторые регрессии все еще могут выпадать из области действия тестов, но все же общее качество релизов значительно улучшилось с момента реализации фреймворка тестирования.

11.8. Выученные уроки

Один из наиболее важных уроков, усвоенных в ходе разработки библиотеки matplotlib, может быть описан с помощь выражения Le Corbusier: "Хорошие архитекторы используют заимствования". Авторы ранних версий библиотеки matplotlib были в основном учеными, самостоятельно изучившими программирование и пытавшимися выполнить поставленную задачу, а не обученными специалистами в области компьютерных наук. Поэтому с первого раза и не была создана подходящая внутренняя архитектура библиотеки. Решение о реализации доступного пользователю уровня сценариев, в большей степени совместимого с API MATLAB, принесло пользу проекту в трех различных аспектах: был предоставлен проверенный временем интерфейс для создания и изменения графиков, стал возможным простой переход к использованию matplotlib частью пользователей из большой пользовательской базы системы MATLAB, и, что наиболее важно для нас в контексте архитектуры matplotlib, у разработчиков появилась возможность провести несколько рефакторингов внутреннего объектно-ориентированного API без вмешательства в работу пользователей, так как интерфейс сценариев оставался неизменным. Хотя у нас также были и пользователи API (в отличие от пользователей интерфейса сценариев), большая часть этих пользователей обладала достаточным опытом, чтобы адаптировать свои разработки к изменениям API. Пользователи интерфейса сценариев, с другой стороны, могут разрабатывать код один раз и обладать объективной уверенностью в том, что он будет работать стабильно со всеми последующими релизами.

В ходе реализации внутреннего API рисования, хотя мы и использовали заимствования из GDK, не было приложено достаточных усилий для того, чтобы выяснить, подходит ли в нашем случае данный API рисования, поэтому нам пришлось затратить значительные усилия для того, чтобы после завершения разработки множества систем вывода данных с использованием этого API, расширить функции этих систем путем использования более простого и гибкого API рисования. Нам было бы удобно использовать спецификацию для операций рисования формата PDF [Ent11b], которая была создана с учетом многолетнего опыта работников компании Adobe, полученного при создании спецификации формата PostScript; это позволило бы нам получить начальную совместимость с самим форматом PDF, фреймворком Quartz Core Graphics и инструментарием для рисования Enthought Enable Kiva [Ent11a].

Одним из недостатков языка Python является то, что из-за простоты и выразительности этого языка разработчики обычно считают, что проще повторно спроектировать и реализовать необходимые существующие в других пакетах функции, чем провести работу по интеграции кода из этих пакетов. Для библиотеки matplotlib на ранних этапах развития была бы полезной интеграция с существующими модулями и API, такими, как тулкиты Kiva и Enable от организации Enthought, которые решают аналогичные проблемы, вместо повторной реализации функций. Интеграция с существующими пакетами, однако, является обоюдоострым мечом, так как она может сделать сборки и релизы более сложными и негативно повлиять на гибкость внутренней разработки.

На главную -> MyLDP -> Тематический каталог ->

MediaWiki

Глава 12 из книги "Архитектура приложений с открытым исходным кодом", том 2.

Оригинал: MediaWiki
Автор: Sumana Harihareswara, Guillaume Paumier
Дата публикации: 1 Мая 2012 г.
Перевод: А.Панин
Дата перевода: 24 Мая 2013 г.

С самого начала приложение MediaWiki разрабатывалось как специфический программный продукт для проекта Wikipedia. Деятельность разработчиков была направлена на упрощение возможности его повторного применения сторонними пользователями, но процесс разработки и предназначение для работы в рамках проекта Wikipedia значительным образом повлияли на архитектуру приложения MediaWiki в течение истории его развития.

Wikipedia является одним из десяти наиболее популярных вебсайтов в мире, на данный момент обслуживающим около 400 миллионов уникальных посетителей в месяц. Этот вебсайт обрабатывает более 100000 обращений в секунду. Вебсайт Wikipedia не использует коммерческие рекламные объявления для финансирования своей деятельности; он поддерживается в полном объеме некоммерческой организацией Wikimedia Foundation, основным источником финансирования которой являются пожертвования. Это значит, что программный продукт MediaWiki должен не только работать на находящемся в первой десятке по популярности вебсайте, но также делать это в условиях ограниченного бюджета. Для удовлетворения этих требований приложение MediaWiki должно было быть разработано с большим вниманием к производительности, возможностям кэширования и оптимизациям. Ресурсоемкие возможности, которые не могут быть включены в Wikipedia либо удаляются, либо отключаются с помощью переменных конфигурации; в ходе разработки осуществляется бесконечное балансирование между производительностью и возможностями приложения.

Влияние проекта Wikipedia на архитектуру системы MediaWiki не ограничивается производительностью. В отличие от систем управления содержимым вебсайта (CMS) общего назначения, приложение MediaWiki изначально разрабатывалось со специфической целью: для поддержки сообщества, которое создает и курирует свободно распространяемые знания в рамках открытой платформы. Это значит, например, что MediaWiki не поддерживает такие возможности, реализуемые в рамках корпоративных систем управления содержимым вебсайта, как поток обработки публикаций или списки контроля доступа, но предоставляет множество различных инструментов для борьбы со спамом и вандализмом.

Таким образом, с самого начала требования и действия постоянно растущего сообщества участников проекта Wikipedia повлияли на процесс разработки приложения MediaWiki и наоборот. Архитектура приложения MediaWiki множество раз претерпевала изменения благодаря таким начатым или предложенным сообществом инициативам, как создание проекта Wikimedia Commons или функция флагов для изменений (Flagged Revisions). Разработчики реализовывали основные архитектурные изменения при появлении их необходимости в процессе использования приложения MediaWiki участниками проекта Wikipedia.

Приложение MediaWiki также приобрело обширный круг сторонних пользователей благодаря изначальному развитию в форме программного обеспечения с открытым исходным кодом. Сторонние пользователи знают о том, что пока такой популярный вебсайт, как Wikipedia, использует приложение MediaWiki, это программное обеспечение будет поддерживаться в рабочем состоянии и совершенствоваться. Приложение MediaWiki на самом деле в первую очередь предназначалось для сайтов организации Wikimedia, но были предприняты усилия для того, чтобы расширить область его применения и сделать его более подходящим для удовлетворения потребностей сторонних пользователей. Например, в данный момент в составе приложения MediaWiki поставляется замечательный веб-установщик, упрощающий процесс установки по сравнению с процессом, в ходе которого все действия должны выполняться с помощью командной оболочки и программное обеспечение содержит жестко заданные пути к файлам, специфичные для проекта Wikipedia.

Все же, приложение MediaWiki остается программным обеспечением проекта Wikipedia, что видно при рассмотрении его истории развития и архитектуры.

Данная глава организована следующим образом:

12.1. Исторический обзор

Фаза I: UseModWiki

Проект Wikipedia был начат в январе 2001 года. В этот момент он был в большей степени экспериментом, проводимым с целью проверки возможности ускоренной генерации содержимого для проекта Numpedia, энциклопедии с бесплатными, но оцениваемыми пользователями данными, созданной Jimmy Wales. Так как проект был экспериментом, энциклопедия Wikipedia изначально работала под управлением UseModWiki, существующей в тот момент системы wiki, разработанной с использованием языка Perl и компонента CamelCase, хранящей все страницы в виде отдельных текстовых файлов без записи истории сделанных изменений и распространявшейся в соответствии с условиями лицензии GPL.

Позднее стало ясно, что компонент CamelCase не подходит для именования статей энциклопедии. В конце января 2001 года разработчик UseModWiki и участник проекта Wikipedia Clifford Adams добавил новую возможность в UseModWiki: свободные ссылки; т.е., возможность связывания страниц с использованием специальных синтаксических конструкций (двойных квадратных скобок) вместо автоматических ссылок, создаваемых CamelCase. Спустя несколько недель, проект Wikipedia перешел к использованию новой версии системы UseModWiki с поддержкой свободных ссылок и включил эту возможность.

Хотя эта начальная фаза развития и не относится к процессу создания системы MediaWiki, она описывает контекст развития и показывает, что даже перед созданием приложения MediaWiki, проект Wikipedia развивал возможности используемого программного обеспечения в соответствии со своими требованиями. Приложение UseModWiki также повлияло на некоторые возможности приложения MediaWiki; например, на его язык разметки. Страница ностальгии проекта Wikipedia содержит полную копию базы данных Wikipedia на декабрь 2001 года, когда проект Wikipedia все еще использовал приложение UseMediaWiki.

Фаза II: Сценарий PHP

В 2001 году вебсайт Wikipedia не находился в первой десятке популярных вебсайтов; это был малоизвестный проект, расположенный в неизвестной области сети, не посещаемый большинством поисковых машин и работающий на единственном сервере. При этом производительность уже была проблемой, в первую очередь из-за того, что приложение UseModWiki хранило свои данные в базе данных, представленной в виде обычных файлов. В это время участники проекта Wikipedia опасались того, что вебсайт не выдержит трафика, аналогичного генерируемому при просмотре статей на таких ресурсах, как New York Times, Slashdot и Wired.

Поэтому летом 2001 года участник проекта Wikipedia Magnus Manske (который стал впоследствии студентом университета) начал работу над отдельной системой управления содержимым вебсайта для проекта Wikipedia в свое свободное время. Он решил улучшить производительность программного обеспечения проекта Wikipedia путем использования работающего с базой данных приложения, а также добавить в него специфические для проекта Wikipedia возможности, не предоставляемые "стандартной" системой wiki. Разработанное с использованием языка PHP и базы данных MySQL новое приложение было названо просто "сценарий PHP", "PHP wiki", "программное обеспечение Wikipedia" или "фаза II".

Этот сценарий на языке PHP стал доступен в августе 2001 года, был размещен на сайте SourceForge в сентябре и тестировался до конца 2001 года. Так как проект Wikipedia испытывал сложности из-за повторяющихся проблем с производительностью в условиях растущего трафика, англоязычный раздел энциклопедии Wikipedia в конечном итоге перешел от использования приложения UseModWiki к использованию данного сценария на языке PHP в январе 2002 года. Разделы для других языков, также созданные в 2001 году, также медленно мигрировали, причем некоторые из них использовали приложение UseModWiki до 2004 года.

Так как программное обеспечение, разработанное с использованием языка PHP, работает с базой данных MySQL, сценарий на языке PHP был первым вариантом программного обеспечения, которое впоследствии стало известно под названием MediaWiki. В рамках данного сценария были впервые реализованы такие критические возможности, используемые и сегодня, как пространства имен для организации содержимого (включая страницы обсуждений), оболочки и специальные страницы (включая страницы отчетов об обслуживании, страницы со списком внесенных изменений и списком пользователей).

Фаза III: MediaWiki

Несмотря на улучшения, внесенные благодаря использованию нового сценария на языке PHP с базой данных, комбинация возрастающего объема трафика, ресурсоемких функций и ограниченных аппаратных ресурсов приводила к проблемам с производительностью вебсайта проекта Wikipedia. В 2002 году Lee Daniel Crocker снова переписал код, назвав новое программное обеспечение "Фаза III" (http://article.gmane.org/gmane.science.linguistics.wikipedia.technical/2794). Так как работа сайта периодически нарушалась из-за сложностей, Lee решил, что "времени на основательное проектирование и разработку решения просто нет", поэтому он "просто реорганизовал существующую архитектуру с целью улучшения производительности и переработал весь код". В код были добавлены функции профилирования для отслеживания медленно выполняющихся функций.

Программное обеспечение "Фаза III" использовало тот же основной интерфейс и было спроектировано таким образом, чтобы выглядеть и взаимодействовать с пользователем в большей степени аналогично программному обеспечению "Фаза II" настолько, насколько это возможно. Также было добавлено несколько таких новых функций, как новая система загрузки файлов, двухсторонние списки различий в изменениях содержимого страницы и ссылки между разделами.

Другие функции были добавлены в течение 2002 года и включали новые страницы обслуживания, а также возможность редактирования с помощью двойного клика. При этом проблемы с производительностью снова начали проявляться. Например, в ноябре 2002 года администраторам пришлось временно отключить функцию записи статистики количества просмотров и сайта, которая приводила к выполнению двух операций записи информации в базу данных при каждом просмотре страницы. Они также временами переводили сайт в режим "только для чтения" для поддержания возможности чтения статей и отключения ресурсоемких страниц обслуживания в течение периодов высокой загрузки сайта из-за проблем с блокировкой таблиц.

В начале 2003 года разработчики обсуждали, нужно ли провести повторное проектирование и изменение архитектуры программного обеспечения перед тем, как станет невозможно бороться с нагрузками, либо продолжить дополнять и улучшать существующую кодовую базу. Они выбрали второй вариант в большей степени из-за того, что большинство разработчиков относилось достаточно положительно к существующей кодовой базе и было уверено в том, что будущие улучшения будут достаточны для поддержания темпов роста вебсайта.

В июне 2003 года администраторы добавили второй сервер, являющийся первым сервером базы данных, отделенным от веб-сервера. (Новая машина также являлась веб-сервером для не англоязычных сайтов проекта Wikipedia.) Балансировка нагрузки между двумя серверами должна была быть настроена позднее в течение текущего года. Администраторы также активировали новую систему кэширования страниц, которая использовала файловую систему для хранения сформированных, готовых к отправке страниц для анонимных пользователей.

Также в июне 2003 года Jimmy Wales создал некоммерческую организацию Wikimedia Foundation для поддержки проекта Wikipedia и управления его инфраструктурой, а также выполнения повседневных операций. Программное обеспечение проекта Wikipedia в июле приобрело официальное название "MediaWiki", полученное в результате игры со словами из названия организации Wikimedia Foundation. В то время считалось, что сложные названия могут смутить пользователей и разработчиков.

В июле в программное обеспечение были добавлены такие новые функции, как автоматически генерируемые оглавления и возможность редактирования разделов в рамках страниц, причем обе эти функции используются и сегодня. Первый выпуск приложения с названием "MediaWiki" состоялся в августе 2003 года и завершил становление приложения, структура которого останется относительно стабильной до сегодняшнего дня.

12.2. Кодовая база и практика разработки приложения MediaWiki

PHP

Фреймворк PHP был выбран для разработки программного обеспечения проекта Wikipedia в ходе работы над приложением "Фаза II" в 2001 году; с того времени приложение MediaWiki органично развивалось и развивается до сих пор. Большинство разработчиков проекта MediaWiki являются добровольцами, работающими над приложением в свое свободное время, а в начале развития проекта их было очень мало. Некоторые архитектурные решения и исключения при взгляде из сегодняшнего дня могут показаться некорректными, но сложно критиковать создателей приложения за отсутствие реализации некоторой абстракции, которая является критичной сегодня, в момент, когда кодовая база была достаточно мала, а затраченное на ее разработку время было ограничено.

Например, MediaWiki использует имена классов без префиксов, которые могут привести к конфликтам в момент, когда разработчики PHP или PECL (Библиотека расширений сообщества PHP - PHP Extension Community Library) добавляют новые классы: класс приложения MediaWiki Namespace должен быть переименован в MWNamespace для совместимости с PHP 5.3. Постоянное использование префикса для всех классов (т.е., "MW") должно упростить включение кода приложения MediaWiki в состав другого приложения или библиотеки.

Использование языка PHP было, возможно, не самым лучшим решением в плане производительности, так как он не использует оптимизации, реализованные в некоторых других динамических языках программирования. Использование языка Java позволило бы получить гораздо лучшую производительность и упростить процесс масштабирования для обслуживания оборудования. С другой стороны, язык PHP очень популярен и его использование упрощает привлечение новых разработчиков.

Даже если приложение MediaWiki все еще содержит "некачественный" устаревший код, значительные улучшения проводились в течение многих лет и новые элементы архитектуры вводились в состав приложения MediaWiki в течение всей истории его развития. Эти улучшения включают в себя классы Parser, SpecialPage и Database, класс Image и иерархию классов FileRepo, иерархии классов ResourceLoader и Action. Приложение MediaWiki начало свое существование без всех этих классов, но все они реализуют функции, которые были доступны с самого начала. Многие разработчики заинтересованы в первую очередь в разработке новых функций и обычно оставляют в стороне вопросы, касающиеся архитектуры, чтобы обратить внимание на них только после окончания разработки, когда отсутствие подходящих архитектурных решений является очевидным.

Безопасность

Так как приложение MediaWiki является платформой для таких известных сайтов, как Wikipedia, основные разработчики и рецензенты кода следуют жестким правилам безопасности. (Ознакомьтесь с подробным руководством.) Для упрощения написания безопасного кода приложение MediaWiki предоставляет разработчикам функции-обертки для доступа к формируемым документам HTML и осуществления запросов к базам данных с удалением управляющих символов. Для нормализации введенных пользователем данных разработчик использует класс WebRequest, который анализирует переданные в составе URL или с помощью формы с POST-запросом данные. Он удаляет "магические кавычки" и слеши, убирает некорректные введенные символы и нормализует последовательности символов Unicode. Атаки на основе межсайтового создания запросов (cross-site request forgery - CSRF) отсекаются путем использования токенов, а атаки на основе межсайтового скриптинга (cross-site scripting - XSS) - путем проверки вводимых символов и удаления управляющих символов из выводимых последовательностей данных, обычно с помощью функции htmlspecialchars() из состава PHP. Приложение MediaWiki также предоставляет (и использует) систему проверки структуры XHTML, реализованную в рамках класса Santizer, а также функции работы с базой данных для предотвращения атак на основе SQL-инъекций.

Конфигурация

Приложение MediaWiki использует тысячи настроек, хранящихся в глобальных переменных PHP. Их значения по умолчанию хранятся в файле DefaultSettings.php, а системный администратор может изменить их значения, отредактировав файл LocalSettings.php.

MediaWiki в значительной степени зависит от глобальных переменных, включая переменные для хранения настроек и работе с контекстом. Глобальные переменные могут вилять на безопасность приложения в зависимости от использования функции register_globals из состава PHP (которая не требуется MediaWiki начиная с версии 1.2). Эта система также ограничивает возможности абстракций конфигурации и затрудняет оптимизацию процесса запуска. Более того, пространство имен конфигурации делится с переменными, используемыми для регистрации пользователей и объектов для управления контекстом, что ведет к потенциальным конфликтам. С точки зрения пользователя, глобальные переменные для конфигурации сделали приложение MediaWiki на первый взгляд сложным для настройки и сопровождения. Процесс разработки MediaWiki стал историей медленного перемещения элементов контекста из глобальных переменных в состав объектов. Хранение элементов управления контекстом в переменных объектов позволяет осуществлять более гибкое повторное использование этих объектов.

12.3. База данных и хранилище текста

Приложение MediaWiki использует реляционную базу данных с момента создания программного обеспечения с названием "Фаза II". Стандартной (и поддерживаемой лучшим образом) системой управления базами данных (database management system - DBMS) для MediaWiki является система MySQL, используемая всеми сайтами организации Wikimedia, но другие системы управления базами данных (такие, как PostgreSQL, Oracle и SQLite) поддерживаются силами сообщества. Системный администратор может выбрать систему управления базами данных в процессе установки приложения MediaWiki, при этом MediaWiki предоставляет и абстракцию для базы данных, и абстракцию уровня запросов, которая упрощает доступ к базе данных для разработчиков.

Схема базы данных
Рисунок 12.1: Схема базы данных

В данный момент база данных содержит множество таблиц. Многие таблицы относятся к функциям хранения содержимого wiki (т.е., таблицы page, revision, category и recentchanges). Другие таблицы содержат данные пользователей (таблицы user, user_groups), мультимедийных файлов (image, filearchive), кэширования (objectcache, l10n_cache, querycache) и инструментов для внутренних операций (таблица job для хранения очередей задач), а также другие данные, как показано на Рисунке 12.2. (Доступна документация с полным описанием структуры базы данных MediaWiki.) Индексы и итоговые таблицы интенсивно используются MediaWiki, так как SQL-запросы, затрагивающие большие количества строк, могут оказаться чрезвычайно ресурсоемкими, особенно в случае сайтов организации Wikimedia. Запросы без индексов обычно отклоняются.

В течение многих лет было проведено большое количество изменений схемы базы данных, при этом наиболее важным было разделение хранилища текстовых данных и данных изменений в MediaWiki версии 1.5.

Основные таблицы данных в MediaWiki версий 1.4 и 1.5
Рисунок 12.2. Основные таблицы данных в MediaWiki версий 1.4 и 1.5

В модели базы данных версии 1.4 содержимое страниц хранилось в двух важных таблицах: cur (содержащей текст и метаданные последней ревизии страницы) и old (содержащей данные предыдущих ревизий); удаленные страницы хранились в таблице archive. Когда выполнялось редактирование, действующая ревизия копировалась в таблицу old, а новая ревизия сохранялась в таблицу cur. При переименовании страницы заголовок страницы обновлялся в метаданных всех устаревших ревизий из таблицы old, что было достаточно длительной операцией. При удалении страницы все ее элементы из таблиц cur и old должны были копироваться в таблицу archive перед самим удалением; эта операция подразумевала перемещение текстовых данных всех ревизий, которые могли иметь большой объем и, таким образом, выполнение операции могло занимать большой промежуток времени.

В модели базы данных версии 1.5 метаданные и текст ревизий были разделены: таблицы cur и old были заменены на таблицы page (для хранения метаданных страниц), revision (для хранения метаданных всех ревизий, устаревших или действующих) и text (для хранения текстовых данных всех ревизий, устаревших и действующих или удаленных). Теперь в случае редактирования метаданные ревизии не должны копироваться между таблицами: вполне достаточно вставки нового элемента и обновления указателя page_latest. Также, метаданные ревизии больше не включают в себя заголовка страницы, а содержат только ее идентификатор: это обстоятельство исключает необходимость переименования всех ревизий при переименовании страницы.

Таблица revision содержит метаданные для каждой ревизии, но не текст ревизий; наоборот, в составе метаданных содержится идентификатор текста, указывающий на элемент таблицы text, содержащий соответствующие текстовые данные. При удалении страницы текст всех ревизий страницы остается на том же месте и его перемещения в другую таблицу попросту не требуется. Таблица text содержит соответствующие идентификаторам текстовые данные; поле flags указывает на то, сжаты ли текстовые данные с помощью gzip (для экономии дискового пространства) или на то, являются ли данные простым указателем на внешнее хранилище текстовых данных. Сайты организации Wikimedia используют кластер для формирования внешнего хранилища данных на основе системы MySQL и хранения в нем данных множества ревизий. Первая ревизия текстовых данных хранится в полном объеме, а следующие ревизии той же страницы хранятся в форме списка различий новой и предыдущей ревизии; впоследствии данные сжимаются с помощью gzip. Так как ревизии группируются в соответствии со страницами, они схожи, поэтому списки различий относительно малы и сжатие с помощью gzip отлично работает. Степень сжатия, которая достигается при работе с сайтами организации Wikimedia, составляет около 98%.

На стороне аппаратного обеспечения приложение MediaWiki применяет встроенную систему балансировки нагрузки, добавленную в состав приложения в 2004 году во время выпуска MediaWiki версии 1.2 (когда проект Wikipedia получил в свое распоряжение второй сервер - значительное приобретение в то время). Балансировщик нагрузки (код PHP в составе приложения MediaWiki, который решает, с каким сервером соединиться) на данный момент является критической частью инфраструктуры организации Wikimedia, которая оказывает влияние на некоторые решения, реализованные в форме алгоритмов из кода. Системный администратор может установить в файле конфигурации приложения MediaWiki один ведущий сервер и любое количество ведомых серверов баз данных; значение приоритета может быть установлено для каждого из серверов. Балансировщик нагрузки будет переадресовывать все операции записи ведущему серверу и балансировать операции чтения в зависимости от приоритетов серверов. Он также следит за задержкой при копировании данных каждым из ведомых серверов. Если задержка при записи ведомым сервером превышает 30 секунд, он не будет получать каких-либо очередей чтения, что позволит завершить выполнение операции; если все ведомые серверы испытывают задержки более 30 секунд, приложение MediaWiki автоматически переведет себя в режим работы с выполнением исключительно операций чтения данных.

Система "хронологической защиты" приложения MediaWiki позволяет быть уверенным в том, что задержка при копировании данных никогда не приведет к тому, что пользователю будет показана страница с информацией, при ознакомлении с которой можно будет сделать вывод о том, что недавно выполненная операция еще не завершилась: например, если пользователь переименовывает страницу, другой пользователь может все еще видеть ее старое название, но тот, кто ее переименовал всегда будет видеть новое имя, так как он является тем, кто ее переименовал. Это поведение реализуется путем сохранения данных о ведущем сервере в рамках пользовательской сессии в том случае, если выполненный запрос привел к отправке запроса записи к базе данных. В следующий раз, когда пользователь осуществит запрос чтения, балансировщик нагрузки получит данные о расположении сервера из данных сессии и попытается выбрать ведомый сервер, на который были скопированы данные, после чего выполнить запрос. Если сервер не доступен, система будет ожидать ввода сервера в эксплуатацию. Другим пользователям может казаться, что действие еще не выполнено, но хронология остается последовательной для каждого из пользователей.

12.4. Запросы, кэширование и доставка данных

Процесс выполнения веб-запроса

Файл index.php является основной точкой входа приложения MediaWiki и принимает большинство запросов, обрабатываемых серверами приложения (т.е., запросов, не обрабатываемых инфраструктурой кэширования, о которой написано ниже). Исполняемый код из файла index.php производит все проверки безопасности данных, загружает стандартные параметры настройки из файла includes/DefaultSettings.php, устанавливает параметры настройки с помощью файла исходного кода includes/Setup.php, после чего применяет параметры настройки вебсайта, заданные в файле LocalSettings.php. После этого создается объект MediaWiki ($mediawiki), а также объект Title ($wgtitle), в зависимости от параметров, задающих заголовок и действие в рамках запроса.

Файл index.php может принимать множество описывающих действие параметров в рамках запроса с использованием URL; стандартным параметром является параметр view, с помощью которого производится обычный вывод содержимого статьи. Например, запрос https://en.wikipedia.org/w/index.php?title=Apple&action=view выводит содержимое статьи с названием "Apple" из англоязычной энциклопедии Wikipedia. (Запросы просмотра статей обычно упрощаются с помощью механизма перезаписи URL, а в данном случае строка URL примет следующий вид: https://en.wikipedia.org/wiki/Apple.) Другими часто используемыми параметрами являются edit (применяемый для открытия статьи с целью редактирования), submit (для предварительного просмотра или сохранения статьи), history (для показа истории редактирования статьи) и watch (для добавления статьи в пользовательский список наблюдения). Административные действия выполняются с помощью параметров delete (для удаления статьи) и protect (для запрета редактирования статьи).

Функция MediaWiki::performRequest() вызывается впоследствии для выполнения наибольшего количества работы, связанной с обработкой запроса в форме URL. Она проверяет наличие некорректных заголовков, ограничений чтения, локальных перенаправлений и циклов перенаправлений, а также устанавливает, направлен ли запрос на показ обычной или специальной страницы.

Запросы обычных страниц осуществляются с помощью вызовов функции MediaWiki::initializeArticle() для создания объекта статьи Article для страницы ($wgArticle), после чего вызывается функция MediaWiki::preformAction(), которая выполняет "стандартные" действия. Как только выполнение действия завершается, функция MediaWiki::finalCleanup() завершает выполнение запроса, принудительно выполняя транзакции на уровне базы данных, выводя HTML-документ и запуская отложенные обновления данных из очереди задач. Функция MediaWiki::restInPeace() осуществляет выполнение отложенных обновлений данных и корректно завершает выполнение задачи.

Если запрашиваемая страница является специальной страницей (т.е., не обычной страницей wiki с содержимым, а такой специальной страницей с подробностями о функционировании программного обеспечения, как страница статистики Statistics), вместо функции initializeArticle() вызывается функция SpecialPageFactory::executePath(); впоследствии выполняется соответствующий сценарий PHP. Специальные страницы позволяют выполнять все типы нестандартных задач, при этом каждая страница имеет специфическую цель, обычно не зависящую от любой из статей, а также содержимого этой статьи. Специальные страницы, среди прочего, позволяют знакомиться с различными типами отчетов (списками недавних обновлений, журналами событий, страницами без категорий) а также работать с инструментами администрирования wiki (блокировать пользователей, изменять права пользователей). Их принцип работы зависит от их функций.

Многие функции содержат код профилирования, который делает возможным отслеживание хода исполнения функций для отладки в случае включения профилирования. Профилирование осуществляется с помощью вызовов функций wfProfileIn и wfProfileOut, которые включают и отключают функцию профилирования соответственно; обе функции принимают в качестве параметра имя функции. На сайтах организации Wikimedia с целью сохранения производительности профилирование производится только с использованием некоторого процента всех запросов. Приложение MediaWiki отправляет UDP-пакеты центральному серверу, который накапливает их и формирует данные профилирования на их основе.

Кэширование

Приложение MediaWiki оптимизировано с целью повышения производительности, так как оно играет ключевую роль в функционировании вебсайтов организации Wikimedia, а также из-за того, что оно является частью большой функционирующей экосистемы, которая повлияла на его архитектуру. Инфраструктура кэширования данных организации Wikimedia (разделенная на уровни) наложила ограничения на возможности приложения MediaWiki; разработчики пытались устранить проблемы, не стараясь подстроиться под принцип работы в значительной степени оптимизированной инфраструктуры кэширования организации Wikimedia, сформированной вокруг приложения MediaWiki, а делая приложение MediaWiki более гибким таким образом, чтобы оно могло работать в рамках этой инфраструктуры без ущерба требуемым возможностям производительности и кэширования. Например, по умолчанию приложение MediaWiki выводит IP-адрес пользователя в правом верхнем углу страницы (для языков с написанием слева направо) в качестве напоминания о том, как как пользователи идентифицируются программным обеспечением в период работы с системой. Переменная конфигурации $wgShowIPHeader позволяет системному администратору отключить эту возможность, делая тем самым содержимое страницы независимым от пользователя: все анонимные посетители смогут получать одну и ту же версию каждой страницы.

Первый уровень кэширования (используемый на сайтах организации Wikimedia) состоит из прокси-серверов обратного кэширования (Squid), которые перехватывают и выполняют большинство запросов до момента их обработки с помощью серверов приложения MediaWiki. Серверы Squid содержат статические версии полностью сформированных страниц, пригодных для чтения пользователями, не совершившими вход в систему. Приложение MediaWiki изначально поддерживает механизмы взаимодействия с серверами Squid и Varnish, а также интегрирует их в систему уровня кэширования для выполнения таких действий, как, например, отправка уведомления им о необходимости удаления страницы из кэша после ее изменения. Для пользователей, совершивших вход в систему, а также других запросов, которые не могут обрабатываться с помощью серверов Squid, кэширующие серверы пересылают запросы напрямую веб-серверу (Apache).

Второй уровень кэширования реализуется в ходе создания и формирования страницы приложением MediaWiki из множества объектов, многие из которых могут кэшироваться для сокращения количества последующих вызовов. Эти объекты включают в себя интерфейс страницы (боковую панель, меню, текст пользовательского интерфейса) и ее содержимое, сформированное в ходе разбора текстовых данных с разметкой wiki (wikitext). Система кэширования объектов в оперативной памяти была доступна в приложении MediaWiki начиная с версии 1.1 (выпущенной в 2003 году) и очень важна для предотвращения повторных разборов больших и сложных страниц.

Данные сессии при входе в систему также могут сохраняться с помощью системы Memcached, что позволяет организовать прозрачную работу механизма сессий в окружении, состоящем из множества серверов с системой балансировки нагрузки (инфраструктура организации Wikimedia формировалась с расчетом на использование системы балансировки нагрузки LVS совместно с PyBal).

Начиная с версии 1.16 приложение MediaWiki использует отдельный кэш объектов для хранения локализованных строк пользовательского интерфейса; этот кэш был добавлен после выявления того, что большое количество объектов, сохраняемых с помощью системы Memcached, представляет собой локализованные с учетом используемого языка сообщения пользовательского интерфейса. Система кэширования реализует возможность быстрого получения отдельных сообщений из баз данных констант (constant databases - CDB), т.е., файлов, содержащих пары ключ-значение. Базы данных констант позволяют снизить затраты памяти и время запуска системы в стандартных условиях; они также используются для функционирования кэшей уровня wiki.

Последний уровень кэширования представлен системой кэширования байткода PHP, обычно активируемой для ускорения приложений на языке PHP. Компиляция может быть длительной; для преодоления необходимости компиляции сценариев PHP в байткод каждый раз при их запуске может быть использован ускоритель PHP, позволяющий хранить скомпилированный байткод и выполнять его непосредственно без компиляции. Приложение MediaWiki будет "просто работать" совместно с многими ускорителями, такими, как APC, PHP accelerator и eAccelerator.

Ввиду большой нагрузки на инфраструктуру организации Wikimedia, приложение MediaWiki было оптимизировано для работы с завершенной многослойной распределенной инфраструктурой кэширования данных. Несмотря на это, приложение также предоставляет возможность использования дополнительной упрощенной системы кэширования, использующей файловую систему для хранения полностью сформированных выводимых страниц аналогично кэширующему серверу Squid. Также абстрактный уровень кэширования объектов приложения MediaWiki позволяет хранить объекты в нескольких местах, включая файловую систему, базу данных или кэш байткода.

Модуль ResourceLoader

Как и в случае многих других веб-приложений, интерфейс приложения MediaWiki становился более быстрым и отзывчивым в течение многих лет, причем в большей степени это стало возможным благодаря использованию языка JavaScript. Улучшение пользовательских качеств приложения было начато в 2008 году вместе с разработкой системы для расширенной работы с мультимедийными файлами (т.е., системы для реализации таких функций, как редактирование видеофайлов с помощью веб-приложения), создававшейся для улучшения производительности удаленного пользовательского интерфейса.

Для оптимизации процесса доставки данных сценариев на языке JavaScript и стилей CSS был разработан модуль ResourceLoader, оптимизирующий процесс доставки данных JS и CSS. Начатая в 2009 году разработка была завершена в 2011 году и стала основной функцией приложения MediaWiki начиная с версии 1.17. Модуль ResourceLoader осуществляет доставку данных JS и CSS по требованию, таким образом экономя время, затрачиваемое на загрузку и разбор данных в случаях отсутствия необходимости в этих операциях, например, при использовании устаревших браузеров. Также данный модуль позволяет уменьшить объем кода, сгруппировать ресурсы для уменьшения количества запросов и, кроме того, вставлять изображения с помощью строк URI для данных. (Для получения более подробной информации о модуле ResourceLoader следует обратиться к официальной документации и выступлению Trevor Parscal и Roan Kattouw с названием "Low Hanging Fruit vs. Micro-optimization: Creative Techniques for Loading Web Pages Faster" на конференции OSCON 2011.)

12.5. Языки

Контекст и обоснование

Ключевым фактором успешного процесса создания и распространения свободной информации с участием всех желающих является предоставление этой информации на стольких языках, на скольких это возможно. Сайт Wikipedia доступен на более чем 280 языках и англоязычные статьи энциклопедии составляют менее чем 20% от всех статей. Так как сайт Wikipedia и родственные ему сайты существуют на таком огромном количестве языков, важно не только предоставлять читателям содержимое статей на их родном языке, но также предоставлять в их распоряжение локализованный интерфейс и эффективные инструменты ввода и преобразования текста для того, чтобы участники проекта могли добавлять информацию.

По этой причине системы локализации и интернационализации (l10n и i18n) являются ключевыми компонентами приложения MediaWiki. Система интернационализации производит глубокие изменения данных и затрагивает большое количество программных компонентов; она также является одной из наиболее гибких и функциональных систем. (Существует исчерпывающее руководство по интернационализации и локализации приложения MediaWiki.) Удобство переводчиков обычно оказывается предпочтительнее удобства разработчиков, хотя и считается, что обе группы должны находятся в одинаковых условиях.

Приложение MediaWiki на данный момент локализовано с использованием более чем 350 языков, включая не латинские языки и языки с написанием справа налево (RTL), причем локализации имеют различный статус завершения. Интерфейс и содержимое могут использовать разные языки, а также быть смешаны.

Язык содержимого статей

Изначально приложение MediaWiki использовало кодировки в зависимости от используемых языков, что приводило к множеству проблем; например, сценарии, использующие другие языки, не могли использоваться в заголовках страниц. Вместо различных кодировок была применена кодировка UTF-8. Поддержка отличных от UTF-8 наборов символов была прекращена в 2005 году вместе с кардинальными изменениями схемы базы данных в версии MediaWiki 1.5; в данный момент текст статей должен использовать кодировку UTF-8.

Символы, не доступные на клавиатуре редактирующего статью участника, могут быть заданы и вставлены с помощью инструментов редактирования приложения MediaWiki (Edittools), элемента интерфейса, расположенного ниже окна редактирования; версия этих инструментов, работающая с использованием языка JavaScript, автоматически вставляет выбранный символ в окно редактирования. Расширение WikiEditor для приложения MediaWiki, разработанное в рамках кампании по улучшению пользовательских качеств приложения, объединяет список специальных символов с панелью инструментов редактирования. Другое расширение с названием Narayam предоставляет возможность использования дополнительных методов ввода и возможностей назначения клавиш для символов, не входящих в таблицу ASCII.

Язык интерфейса

Сообщения интерфейса хранились в массивах PHP в форме пар ключ-значение начиная с момента создания программного обеспечения под названием "Фаза III". Каждое сообщение идентифицируется с помощью уникального ключа, который ставится в соответствие различным значениям для различных языков. Ключи задаются разработчиками, вынужденными использовать префиксы для расширений; например, ключи сообщений для расширения UploadWizard начинаются с префикса mwe-uowiz-, где mwe расшифровывается как расширение MediaWiki (MediaWiki extension).

Сообщения приложения MediaWiki могут включать в свой состав заданные программным обеспечением параметры, которые обычно влияют на грамматику сообщения. В общем, для теоретической поддержки любого существующего языка система локализации приложения MediaWiki со временем была усовершенствована и усложнена с целью адаптации к специфичным для языков особенностям и исключениям, которые обычно кажутся странными англоговорящей аудитории.

Например, определения являются неизменяемыми словами в английском языке, но в таких языках, как французский требуется согласование определений с существительными. Если пользователь указал свой пол на странице настроек профиля, для корректного обращения к пользователю в сообщениях интерфейса может использоваться модификатор {{GENDER:}}. Множество других модификаторов включает в свой состав модификатор {{PLURAL:}} для "простых" языковых групп и языков, таких, как арабский, использующих двойные, тройные или краткие записи чисел, а также модификатор {{GRAMMAR:}}, позволяющий выполнять функции грамматических преобразований для таких языков, как финский, грамматические особенности которого предполагают наличие различий или изменений форм слов.

Локализация сообщений

Локализованные сообщения интерфейса приложения MediaWiki находятся в файлах MessagesXx.php, где Xx является кодом ISO-639 для языка (т.е., для французского языка файл будет иметь название MessagesFr.php); используемые по умолчанию сообщения написаны на английском языке и находятся в файле MessagesEn.php. Расширения приложения MediaWiki используют аналогичную систему или хранят все локализованные сообщения в файле с именем <Название-расширения>.i18n.php. Вместе с переродами, файлы сообщений также включают такую специфическую для языков информацию, как форматы даты.

Передача переводов проекту обычно осуществляется путем отправки патчей для файлов исходного кода PHP с именами MessagesXx.php. В декабре 2003 года в рамках выпуска версии 1.1 приложения MediaWiki была представлена система "сообщений из базы данных", представляющая собой подмножество страниц wiki из пространства имен MediaWiki, хранящих сообщения интерфейса. Содержимое страницы wiki с названием MediaWiki:<Ключ-сообщения> является текстом сообщения, более приоритетным, чем значение из файла сообщений PHP. Локализованные версии сообщения хранятся в форме страниц с названиями MediaWiki:<Ключ-ссобщения>/<код-языка>; например, MediaWiki:Rollbacklink/de.

Эта возможность позволила опытным пользователям перевести (и изменить) сообщения интерфейса в рамках своей системы wiki, в ходе процесса не модифицируя поставляемые в составе приложения MediaWiki файлы интернационализации. В 2006 году Niklas Laxstrom создал специальный, значительно доработанный сайт на основе MediaWiki (на сегодняшний день располагающийся по адресу http://translatewiki.net), на котором переводчики могли без лишних сложностей локализовать сообщения интерфейса для всех языков, просто редактируя страницу wiki. После этого обновлялись файлы MessagesXx.php в репозитории исходного кода приложения MediaWiki, из которого они могли автоматически извлекаться любой системой wiki, а также обновляться с помощью расширения LocalisationUpdate. На сайтах организации MediaWiki сообщения из базы данных используются в данный момент только для изменения вида страниц, но не для локализации. Расширения приложения MediaWiki и некоторые такие сопутствующие приложения, как боты также локализуются с помощью сайта translatewiki.net.

Для облегчения понимания переводчиками контекста и значения сообщения интерфейса при разработке приложения MediaWiki хорошей практикой является предоставление документации для каждого сообщения. Эта документация хранится в специальном файле сообщений с кодом языка qqq, который не соответствует какому-либо из реально существующих языков. Документация для каждого сообщения выводится в интерфейсе перевода на сайте translatewiki.net. Другим полезным инструментом является код языка qqx; при его использовании в качестве аргумента параметра &uselang после запроса страницы wiki (т.е., при использовании запроса, аналогичного https://en.wikipedia.org/wiki/Special:RecentChanges?uselang=qqx) приложение MediaWiki выведет имена ключей сообщений вместо их значений при формировании пользовательского интерфейса; это очень полезно для идентификации того, какое сообщение следует перевести или изменить.

Зарегистрированные пользователи могут выбрать свой язык интерфейса на странице настроек в случае необходимости изменения стандартного языка интерфейса вебсайта. Приложение MediaWiki также поддерживает языки, выбираемые в случае неполадок: если сообщение не доступно для выбранного языка, будет выведено сообщение для наиболее схожего языка, который не обязательно должен являться английским. Например, в качестве замены бретонского языка используется французский язык.

12.6. Пользователи

Пользователи представлены в коде с помощью экземпляров класса User, который инкапсулирует все специфические для пользователя настройки (идентификатор, имя, права доступа, пароль, адрес электронной почты, и.т.д.). Клиентские классы используют специальные функции для доступа к этим полям; они выполняют всю работу по определению того, осуществил ли пользователь вход в систему и может ли быть установлено значение запрашиваемого параметра с помощью кук или необходим запрос к базе данных. Большая часть параметров, требуемых для формирования стандартных страниц, сохраняется в куках для сокращения количества запросов к базе данных.

Приложение MediaWiki предоставляет очень неоднородную систему прав доступа с устанавливаемыми правами пользователя, в общем, для выполнения любых возможных действий. Например, для выполнения "отката" (т.е., для "быстрой отмены всех операций редактирования последним пользователем определенной страницы") пользователю требуется разрешение с названием "rollback", выданное по умолчанию для группы пользователей "sysop" приложения MediaWiki. Но это разрешение может быть выдано и другим пользовательским группам или исключительно для данного разрешения может быть выделена отдельная группа (этот подход используется в англоязычной энциклопедии Wikipedia в рамках группы Rollbackers). Изменение прав пользователя осуществляется путем редактирования массива $wgGroupPermissions в файле LocalSettings.php; например, объявление $wgGroupPermissions['user']['movefile'] = true; позволяет всем зарегистрированным пользователям осуществлять переименование файлов. Пользователь может быть членом нескольких групп и наследовать наиболее важные права каждой из них.

Однако, система прав пользователей приложения MediaWiki проектировалась с учетом особенностей сайта Wikipedia: сайта с доступным для всех содержимым и с запретом только некоторых действия для некоторых пользователей. В приложении MediaWiki отсутствует унифицированная концепция всеобъемлющих прав доступа; оно не предоставляет таких традиционных возможностей систем управления содержимым вебасйта, как запрет доступа для чтения или записи при указании темы или типа содержимого. Несколько расширений приложения MediaWiki предоставляют такие возможности с некоторыми ограничениями.

12.7. Содержимое статей

Структура содержимого статей

Концепция пространств имен была использована в течение периода эксплуатации приложения UseModWiki сайтом Wikipedia, причем страницы обсуждений имели заголовок "<Название статьи>/Talk". Формально пространства имен были реализованы Magnus Manske в первом "сценарии PHP". Они были несколько раз повторно реализованы в течение длительного промежутка времени, но сохранили свое предназначение: разделение различных типов содержимого страниц. Они состоят из префикса, отделенного от имени страницы двоеточием (т.е., Talk: или File: и Template:); пространство имен позволяет не использовать префикс для доступа к основному содержимому страницы. Пользователи сайта Wikipedia быстро начали использовать эту возможность и создали сообщество с большим количеством возможностей для участия. Пространства имен доказали необходимость своего существования в качестве функции приложения MediaWiki, так как с помощью них создавались необходимые условия для организации пространства дискуссий сообщества wiki, процессов в рамках сообщества, порталов, профилей пользователей, и.т.д.

Стандартные настройки пространства имен основного содержимого страницы приложения MediaWiki предполагают плоскую организацию (без подстраниц), так как таким образом функционирует сайт Wikipedia, но включить поддержку подстраниц достаточно просто. Они включены в других пространствах имен (т.е., пространстве имен User:, где пользователи могут, например, работать с черновиками статей) и обозначаются специальным образом.

Пространства имен разделяют содержимое на основе типов; в рамках одного пространства имен страницы могут быть организованы на основе тем с использованием категорий, эксплуатируя псевдо-иерархическую схему организации, представленную в версии 1.3 приложения MediaWiki.

Обработка содержимого статей: язык разметки MediaWiki и механизм его разбора

Создаваемое пользователями содержимое страниц сохраняется приложением MediaWiki с использованием не формата разметки HTML, а специфического для MediaWiki языка разметки, иногда называемого "wikitext". Этот язык разметки позволяет пользователям изменять форматирование текста (т.е., делать текст жирным или наклонным с использованием кавычек), добавлять ссылки (с помощью квадратных скобок), подключать шаблоны, включать в состав текста зависимое от контекста содержимое (аналогичное дате или подписи), а также выполнять огромное количество других замечательных вещей. (Подробная документация доступна.)

Для вывода страницы ее содержимое должно быть разобрано и дополнено путем сборки всех внешних или динамических элементов, вызываемых данной страницей, после чего должна быть произведена конвертация в корректное представление документа HTML. Система разбора языка разметки является одной из наиболее важных частей приложения MediaWiki, которую сложно изменить или улучшить. Так как возможность вывода в формате HTML миллионов страниц по всему миру зависит от системы разбора языка разметки, эта система является чрезвычайно стабильной.

Язык разметки не был формально описан с самого начала; он был создан на основе языка разметки приложения UseModWiki, а после этого изменялся и развивался в соответствии с предъявляемыми к нему требованиями. В условиях отсутствия формальной спецификации, язык разметки приложения MediaWiki превратился в сложный и своеобразный язык, полностью совместимый исключительно с системой разбора языка разметки приложения MediaWiki; он не может быть представлен формальной грамматикой. О спецификация применяемой на данный момент системы разбора языка разметки в шутку упоминают, как об "описании всего того, что система разбора языка разметки извлекает из данных в формате wikitext с добавлением описания нескольких сотен вариантов тестирования".

Было предпринято множество попыток разработки альтернативных систем разбора языка разметки, но ни одна из них не увенчалась успехом. В 2004 году экспериментальная система разделения текста была разработана Jens Frank с целью разбора данных в формате wikitext и впоследствии применена на сайте Wikipedia; ее пришлось отключить после трех дней эксплуатации из-за низкой производительности системы резервирования участков памяти для массивов языка PHP. С того времени большая часть задач по разбору языка разметки производилась с использованием огромного количества регулярных выражений и множества вспомогательных функций. Разметка wiki, а также множество специальных случаев, которые система ее разбора должна учитывать, стали относительно более сложными, что еще в большей степени затруднило последующие попытки разработки системы разбора языка разметки.

Важным улучшением являлась выполненная Tim Starling переработка кода препроцессора, осуществленная в версии 1.12 приложения MediaWiki, причем основной мотивацией разработчика было желание увеличить производительность разбора данных страниц со сложными шаблонами. Препроцессор конвертирует данные в формате wikitext в представляющее части документа (включающее вызовы шаблонов, функции системы разбора языка разметки, функции тэгов, заголовки разделов и несколько других структур) дерево XML DOM с пропуском таких "неиспользуемых ветвей", как операторы #switch без последующих условий и неиспользуемые стандартные значения для аргументов шаблона при его разборе. После этого система разбора языка разметки обходит структуру DOM и преобразует ее данные в формат HTML.

Недавняя работа над визуальным редактором для приложения MediaWiki привела к необходимости усовершенствования процесса разбора языка разметки (и его ускорения), таким образом была вновь начата работа над системой разбора языка разметки и над промежуточными уровнями представлений в диапазоне между языком разметки MediaWiki и окончательным документом в формате HTML (обратитесь к разделу "Планы на будущее", расположенному ниже).

Специальные слова и шаблоны

Приложение MediaWiki позволяет использовать "специальные слова", с помощью которых изменяется стандартное отображение страницы или в ее состав включаются динамические элементы. Специальные слова делятся на категории: модификаторы поведения __NOTOC__ (для автоматического сокрытия оглавления) или __NOINDEX__ (для сообщения поисковым машинам о том, что страницы не должна индексироваться); переменные, такие, как {{CURRENTTIME}} или {{SITENAME}}; и функции системы разбора языка разметки, т.е., специальные слова, которые могут принимать параметры, такие, как {{lc:<string>}} (для вывода строки, передаваемой в качестве параметра <string>, в нижнем регистре). Такие конструкции, как {{GENDER:}}, {{PLURAL:}} и {{GRAMMAR:}} используются для локализации пользовательского интерфейса и являются функциями системы разбора языка разметки.

Наиболее часто применяемым способом включения содержимого других страниц в состав страницы приложения MediaWiki является использование шаблонов. На самом деле шаблоны были предназначены для включения одной и той же информации в состав различных страниц, т.е., навигационных панелей или баннеров с сообщениями о техническом обслуживании на страницах статей сайта Wikipedia; возможность создания частей страниц и повторного использования их в составе тысяч статей с централизацией управления ими позволила повысить популярность сайтов, подобных Wikipedia.

Однако, шаблоны также использовались (в том числе и чрезмерно) пользователями для достижения совершенно другой цели. В версии 1.3 приложения MediaWiki появилась возможность передачи параметров шаблонам для изменения их содержимого; возможность добавления стандартного значения параметра (введенная в версии 1.6 приложения MediaWiki) позволила реализовать функциональный язык программирования на основе PHP, который в итоге оказался одной из самых ресурсоемких функций приложения.

Впоследствии Tim Starling разработал дополнительные функции системы разбора языка разметки (расширение ParserFunctions) в качестве временной меры для борьбы с абсурдными конструкциями, создаваемыми пользователями сайта Wikipedia с помощью шаблонов. Этот набор функций включал такие логические структуры, как #if и #switch, а также другие функции, такие, как #expr (для вычисления значений математических выражений) и #time (для форматирования строки времени).

Спустя достаточно короткий промежуток времени, пользователи сайта Wikipedia начали создавать еще более сложные шаблоны с использованием новых функций, которые сравнительно сильно снизили производительность системы разбора языка разметки при работе со страницами, содержащими большое количество шаблонов. Новый препроцессор, включенный в состав версии 1.12 приложения MediaWiki (в качестве важного архитектурного изменения) был реализован для частичного решения этой проблемы. Не так давно разработчики приложения MediaWiki обсуждали возможность использования существующего языка сценариев, возможно Lua, для улучшения производительности.

Мультимедийные файлы

Пользователи загружают файлы с помощью страницы Special:Upload; администраторы могут задать разрешенные типы файлов с помощью списка разрешенных расширений файлов. После загрузки файлы хранятся в директории файловой системы, а миниатюры для предварительного просмотра - в отдельной директории с названием thumb.

Так как организация Wikimedia выполняет образовательную миссию, приложение MediaWiki поддерживает типы файлов, которые могут быть нестандартными для других веб-приложений и систем управления содержимым вебсайта, такие, как векторные изображения SVG и многостраничные документы PDF и DjVu. Они представляются с помощью изображений формата PNG и могут быть отображены и включены в состав страницы также, как и более известные типы изображений, такие, как GIF, JPG и PNG.

После загрузки файла ему ставится в соответствие страница File:, содержащая информацию, введенную загрузившим файл пользователем; это описание в свободной форме обычно включает информацию о правообладателе (имя автора, лицензия) и элементы, описывающие или классифицирующие содержимое файла (описание, расположение, дата, категории, и.т.д). В то время, как отдельные установленные системы wiki могут не предъявлять требований к этой информации, в медиа-библиотеках, таких, как Wikimedia Commons крайне важно предоставлять данные для организации коллекции и уверенности в том, что файлы могут легально распространяться. Было аргументировано утверждение о том, что большая часть этих метаданных фактически должна храниться в рамках такой структуры, поддерживающей выполнение запросов, как таблица базы данных. Это сравнительно упростит не только поиск, но и установление авторства, а также повторное использование материалов сторонними лицами - например, с помощью API.

Большинство сайтов организации Wikimedia позволяет совершать "локальные" загрузки файлов в каждый из разделов wiki, но сообщество пытается хранить распространяемые под свободными лицензиями мультимедийные файлы в свободной медиа-библиотеке организации Wikimedia, Wikimedia Commons. Любой вебсайт организации Wikimedia может отобразить файл, размещенный в Wikimedia Commons также, как в случае его локального размещения. Это правило позволяет преодолеть необходимость загрузки файла в каждый раздел wiki для того, чтобы использовать его там.

В результате приложение MediaWiki изначально поддерживает сторонние репозитории мультимедийных файлов, т.е., возможность доступа к мультимедийным файлам, размещенным в других разделах wiki при помощи API и системы ForeignAPIRepo. Начиная с версии 1.16, любой использующий MediaWiki вебсайт может без лишних сложностей использовать файлы из репозитория Wikimedia Commons с помощью функции InstantCommons. При использовании стороннего репозитория миниатюры для предварительного просмотра хранятся локально с целью экономии пропускной способности сети. Однако, (на данный момент) невозможно загружать файлы в сторонний репозиторий мультимедийных файлов с помощью не относящейся к нему системы wiki.

12.8. Модификации и расширение возможностей MediaWiki

Уровни доступа

Архитектура приложения MediaWiki предусматривает различные способы модификации и расширения возможностей программного обеспечения. Эти операции могу быть выполнены при различных уровнях доступа:

Внешние программы также могут взаимодействовать с приложением MediaWiki посредством его системного API и, в случае его активации, сделать возможным доступ пользователя практически к любой функции и любым данным.

JavaScript и CSS

Приложение MediaWiki может читать и использовать сценарии JavaScript и таблицы стилей CSS на уровне сайта или оболочки с помощью специальных страниц wiki; эти страницы находятся в пространстве имен MediaWiki: и, таким образом, могут редактироваться только пользователями группы "sysops"; например, модификации сценария JavaScript с использованием страницы MediaWiki:Common.js повлияют на все оболочки, модификации таблицы стилей CSS с использованием страницы MediaWiki:Common.css также повлияют на все оболочки, но модификации таблицы стилей CSS с использованием страницы MediaWiki:Vector.css повлияют исключительно на пользователей оболочки с названием "Vector".

Пользователи могут совершать подобные модификации, которые будут затрагивать только используемый ими интерфейс путем редактирования подстраниц их пользовательской страницы (т.е., User:<Имя пользователя>/common.js для изменения сценария JavaScript всех оболочек, User:<Имя пользователя>/common.css для изменения таблицы стилей CSS всех оболочек или User:<Имя пользователя>/vector.css для изменения таблицы стилей CSS оболочки под названием "Vector").

В том случае, если установлено расширение "Gadgets", пользователи из группы "sysops" могут также редактировать гаджеты, т.е., фрагменты кода на языке JavaScript, реализующие функции, которые могут быть включены и выключены пользователями с помощью их страницы настроек. Грядущие разработки в области гаджетов позволят различным разделам wiki совместно использовать гаджеты, что позволит избежать дублирования кода.

Этот набор инструментов имел большой успех и значительным образом повысил степень демократичности процесса разработки приложения MediaWiki. Отдельные разработчики имеют возможность самостоятельно добавлять функции; опытные пользователи могут делиться своими наработками с окружающими, обе группы делают это неофициально с помощью глобально настраиваемых систем, контролируемых пользователями из группы "sysops". Этот фреймворк идеален для небольших, не затрагивающих сторонние системы модификаций и предоставляет более низкий порог вхождения для разработчиков в отличие от сложных модификаций кода с использованием расширений и точек вызова функций.

Расширения и оболочки

Когда модификаций сценариев JavaScript и таблиц стилей CSS не достаточно, приложение MediaWiki предлагает использовать систему точек вызова функций, позволяющую сторонним разработчикам выполнять специфический код на языке PHP перед, после или вместо кода из состава приложения MediaWiki для обработки определенных событий. (Точки вызова функций приложения MediaWiki описаны в документе, расположенном по адресу https://www.mediawiki.org/wiki/Manual:Hooks.) Расширения приложения MediaWiki используют точки вызова функций для внедрения в код.

До того момента, как точки вызова функций были реализованы в приложении MediaWiki, добавление специфического кода на языке PHP подразумевало модификацию основного кода приложения, что было не так просто и не рекомендовалось делать. Первые точки вызова функций были предложены и реализованы Evan Prodromou в 2004 году; большее количество дополнительных точек вызова функций добавлялось при появлении необходимости в них в течение многих лет. С помощью точек вызова функций возможно добавить даже дополнительные возможности в язык разметки приложения MediaWiki, создав расширения тэгов.

Система расширений не идеальна; регистрация расширений осуществляется в ходе выполнения кода при запуске вместо использования кэшированных данных, что ограничивает возможности реализации абстракций и оптимизаций, а также негативно влияет на производительность приложения MediaWiki. Но, в общем, архитектура расширений в данный момент позволила реализовать относительно гибкую инфраструктуру, которая облегчила задачу по вынесению специализированного кода в модули, сдерживанию (высоких) темпов роста объема кода основных систем и добавлению сторонними пользователями функций в приложение MediaWiki.

Наоборот, очень сложно разработать новую оболочку для приложения MediaWiki без повторного изобретения колеса. В приложении MediaWiki оболочки являются классами языка PHP, каждый из которых расширяет возможности родительского класса Skin; они содержат функции, которые получают необходимую для генерации документа в формате HTML информацию. Существующая долгое время оболочка "MonoBook" сложно модифицируема из-за того, что она содержит большое количество специфического для браузеров кода таблиц стилей CSS, используемого для поддержки устаревших браузеров; редактирование шаблона или таблиц стилей CSS требовало множества последовательных изменений для поддержки совместимости со всеми браузерами и платформами.

API

Еще одной основной точкой входа для приложения MediaWiki, помимо файла index.php, является файл api.php, используемый в качестве API (интерфейса программирования приложений) для осуществления веб-запросов с использованием формата данных для программного доступа.

Пользователи сайта Wikipedia ранее создавали "ботов", которые получали данные путем обработки генерируемых приложением MediaWiki данных в формате HTML; этот метод был очень не надежным и много раз давал сбои. Для исправления ситуации разработчики представили интерфейс только для чтения данных (реализованный в файле query.php), который впоследствии был усовершенствован до полнофункционального поддерживающего программные операции чтения и записи данных API, предоставляющего прямой высокоуровневый доступ к информации из базы данных приложения MediaWiki. (Объемная документация для API доступна.)

Клиентские программы могут использовать API для входа в систему, получения данных и отправки изменений. API поддерживает как тонкие веб-клиенты на языке JavaScript, так и пользовательские приложения. Практически все действия, которые могут быть выполнены с помощью веб-интерфейса, также в общем случае могут выполнены с использованием API. Клиентские библиотеки, реализующие функции для доступа к API приложения MediaWiki доступны для многих языков программирования, включая языки Python и .NET.

12.9. Планы на будущее

Начатый единственным PHP-разработчиком в течение летних каникул проект увеличился в масштабе до приложения MediaWiki, отлаженной и стабильной системы wiki, под управлением которой работает находящийся в первой десятке по популярности вебсайт, использующий рабочую инфраструктуру удивительно малых масштабов. Это стало возможным благодаря постоянному процессу оптимизации с целью повышения производительности, последовательным архитектурным изменениям и команде замечательных разработчиков.

Эволюция веб-технологий и развитие проекта Wikipedia обусловили будущие усовершенствования и новые функции, некоторые из которых потребовали значительных изменений архитектуры приложения MediaWiki. Это хорошо прослеживается на примере реализации проекта нового визуального редактора, которая привела к восстановлению работы над системой разбора языка разметки и над самим языком разметки, его преобразованием в структуру DOM и финальным представлением в формате HTML.

MediaWiki является инструментом, используемым в очень различных целях. В рамках проектов организации Wikimedia, например, он используется для создания курирования энциклопедии (Wikipedia), для функционирования медиа-библиотеки большого объема (Wikipedia Commons), для перевода сканированных оригинальных текстов (Wikisource), и.т.д. В других условиях приложение MediaWiki используется в качестве корпоративной системы управления содержимым вебсайта или в качестве репозитория данных, иногда в комбинации с семантическим фреймворком. Эти не запланированные при проектировании приложения специализированные способы эксплуатации, скорее всего, будут продолжать способствовать постоянным изменениям во внутренней структуре программного обеспечения. По существу, архитектура приложения MediaWiki является в значительной степени живой, точно также, как и поддерживающее ее развитие огромное сообщество пользователей.

12.10. Материалы для дополнительного чтения

12.11. Благодарности

Эта глава была написана несколькими людьми совместно. Guillaume Paumier написал большую часть текста, используя информацию от пользователей и основных разработчиков приложения MediaWiki. Sumana Harihareswara координировала проведение интервью и этапы сбора информации. Большая благодарность Antoine Musso, Brion Vibber, Chad Horohoe, Tim Starling, Roan Kattouv, Sam Reed, Siebrand Mazeland, Erik Moller, Magnus Manske, Rob Lanphier, Amir Aharoni, Federico Leva, Graham Pearce и другим людям за предоставление информации и/или рецензирование текста.

На главную -> MyLDP -> Тематический каталог ->

Moodle

Глава 13 из книги "Архитектура приложений с открытым исходным кодом", том 2.
Оригинал: Moodle
Автор: Tim Hunt
Дата публикации: 1 Мая 2012 г.
Перевод: А.Панин
Дата перевода: 10 Июня 2013 г.

Moodle является веб-приложением, обычно используемым в сфере образования. Хотя в данной главе и будет предпринята попытка создания обзора всех аспектов работы приложения Moodle, главным образом в главе будут рассматриваться те аспекты архитектуры Moodle, которые особенно интересны:

Приложение Moodle предоставляет сетевое пространство, в котором студенты и преподаватели могут объединиться для преподавания и получения знаний. Сайт Moodle разделен на курсы (courses). Курс содержит ассоциированный с ним список пользователей (users) с различными ролями, такими, как студент (Student) или преподаватель (Teacher). Каждый курс включает в себя некоторое количество ресурсов (resources) и действий (activities). Ресурс может быть представлен файлом в формате PDF, страницей в формате HTML в рамках приложения Moodle или ссылкой на какой-либо сетевой ресурс. Действие может быть представлено форумом, тестом или ресурсом wiki. В рамках курса эти ресурсы и действия структурированы каким-либо образом. Например, они могут быть сгруппированы на основе логически разделенных тем или календарных недель.

Курс Moodle
Рисунок 13.1: Курс Moodle

Moodle может использоваться в качестве отдельного приложения. В том случае, если вы пожелаете заняться преподаванием курсов по архитектуре программного обеспечения (например), вам придется загрузить приложение Moodle на ваш веб-сервер, установить его, начать создавать курсы и ожидать студентов, которые должны самостоятельно зайти на сайт и зарегистрироваться. В качестве альтернативного решения, в том случае, если вы создаете курсы для учебного заведения значительного размера, Moodle может быть одной из систем, которые вы будете использовать. Возможно, у вас в распоряжении будет инфраструктура, изображенная на Рисунке 13.2.

Типичная архитектура университетских систем
Рисунок 13.2: Типичная архитектура университетских систем

Приложение Moodle в качестве основной функции предоставляет сетевое пространство для преподавания и получения знаний, в отличие от любой из других систем, которые могут потребоваться учебному заведению. Приложение Moodle предоставляет простейшую реализацию других функций, поэтому оно может функционировать или как отдельная система, или как система, интегрированная с другими системами. Приложение Moodle в общем случае выступает в роли, называемой "виртуальное образовательное окружение" (virtual learning enviroment - VLE) или "система управления процессом обучения или курсами" (learning or course management system - LMS, CMS или даже LCMS).

Приложение Moodle является программным обеспечением с открытым исходным кодом или свободным программным обеспечением (GPL). Оно разработано с использованием языка PHP. Приложение может функционировать на большинстве стандартных веб-серверов, на стандартных платформах. Оно требует наличия базы данных и работает с MySQL, PostgreSQL, Microsoft SQL Server или Oracle.

Проект Moodle был начат Martin Dougiamas в 1999 году, когда он работал в Университете Curtin в Австралии. Версия 1.0 была выпущена в 2002 году, в то время, когда PHP 4.2 и MySQL 3.23 являлись доступными технологиями. Это обстоятельство ограничило выбор возможного типа изначальной архитектуры, но впоследствии все значительно изменилось. Текущим релизом приложения Moodle является релиз из серии 2.2.x.

13.1. Обзор принципа работы приложения Moodle

Установка приложения Moodle состоит из трех составных частей:

  1. Код, обычно расположенный в такой директории, как /var/www/moodle или ~/htdocs/moodle. Доступ на запись в эту директорию не должен предоставляться веб-серверу.
  2. База данных, управляемая одной из поддерживаемых систем управления реляционными базами данных (relational database management system - RDMS). На самом деле, приложение Moodle добавляет префикс ко всем именам таблиц, поэтому оно может делить базу данных с другими приложениями в случае необходимости.
  3. Директория moodledata. Это директория, в которой приложение Moodle хранит загружаемые и генерируемые файлы, поэтому данная директория должна быть доступна для записи со стороны веб-сервера. В целях безопасности эта директория должна находиться вне корневой директории веб-сервера.

Все три части могут быть расположены на одном сервере. В качестве альтернативы при работе в окружении с системой балансировки нагрузки может использоваться множество копий кода, по одной на каждом из веб-серверов, но должна использоваться только одна разделяемая копия базы данных и одна директория moodledata, возможно на других серверах.

Конфигурационная информация для всех трех составных частей хранится в файле с названием config.php в корневой директории установки приложения Moodle с именем moodle.

Передача запросов

Moodle является веб-приложением, поэтому пользователи взаимодействуют с ним посредством своих веб-браузеров. С точки зрения приложения Moodle, процесс взаимодействия заключается в отправке ответов на HTTP-запросы. Следовательно, важным аспектом архитектуры Moodle является пространство имен URL и способ доставки данных URL различным сценариям.

Приложение Moodle в данном случае использует стандартный подход языка PHP. Строка URL для просмотра главной страницы курса будет иметь следующий вид: .../course/view.php?id=123, где 123 является уникальным идентификатором курса в базе данных. Строка URL для просмотра дискуссии форума будет выглядеть аналогично .../mod/forum/discuss.php?id=456789. То есть, эти сценарии course/view.php или mod/forum/discuss.php будут обрабатывать эти запросы.

Так проще для разработчика. Для понимания того, как приложение Moodle обрабатывает определенный запрос, следует рассмотреть строку URL и начать читать код в ней. Это неудачное решение с точки зрения пользователя. Эти строки URL, однако, постоянны. Строки URL не изменяются в случае переименования курса или в том случае, когда модератор перемещает дискуссию в другой форум. (Это свойство строк URL является полезным и описано в статье Tim Berners-Lee с названием "Cool URIs don't change".)

Альтернативным подходом, который может показаться полезным, является единая точка входа .../index.php/[дополнительная-информация-для-превращения-запроса-в-уникальный]. После этого отдельный сценарий index.php будет предавать запросы каким-либо образом. Этот подход добавляет уровень абстракции, что всегда любят делать разработчики программного обеспечения. Отсутствие этого уровня абстракции не кажется вредным для приложения Moodle.

Расширения

Как и множество успешных проектов с открытым исходным кодом, проект Moodle построен на основе большого количества работающих совместно с ядром системы расширений. Этот подход удачен, так как он позволяет пользователям изменять и расширять возможности приложения Moodle различными способами. Важным преимуществом системы с открытым исходным кодом является тот факт, что вы можете адаптировать ее к вашим определенным потребностям. Выполнение значительных изменений кода может, однако, привести к большим проблемам при наступлении времени обновления, даже в случае использования удобной системы контроля версий. Позволяя производить так много изменений и добавлять столько новых функций, сколько возможно при условии их реализации в рамках самостоятельных расширений, взаимодействующих с ядром приложения Moodle с помощью описанного API, можно облегчить задачу по изменению возможностей Moodle пользователями в соответствии с их потребностями, а также по распространению внесенных изменений, при этом сохраняя возможность обновления ядра системы Moodle.

Существуют различные пути создания системы в виде ядра, окруженного расширениями. Приложение Moodle использует ядро относительно большого размера, при этом расширения являются строго типизированными. При разговоре о большом размере ядра я имею в виду большое количество функций, реализованных в рамках ядра. Этот подход противоположен используемому в архитектуре, где практически все функции, кроме небольшого загрузчика расширений, реализованы в рамках расширений.

Когда я я говорю о строго типизированных расширениях, я имею в виду то, что вам придется разрабатывать различные типы расширений и использовать различные API в зависимости от того, какой тип функций вы хотите реализовать. Например, новое расширение в виде модуля действий будет значительно отличаться от расширения аутентификации или нового расширения типа вопроса. (Существует полный список типов расширений приложения Moodle.) Этот подход противоположен используемому в архитектуре, где все расширения используют в основном один и тот же API а затем, возможно, соединяются с необходимым им подмножеством точек вызова функций или событий.

В основном, направление развития приложение Moodle предусматривало попытки сокращения размера ядра и переноса большего количества функций в расширения. Эти попытки были в какой-то степени успешны, однако, ввиду расширения набора функций сохранялась тенденция роста размера ядра. В рамках другого направления развития были предприняты попытки стандартизации различных типов расширений настолько, насколько это возможно, так, чтобы такие стандартные функции, как установки и обновление выполнялись аналогично во всех типах расширений.

Расширение приложения Moodle представлено в форме директории, содержащей файлы. Расширение имеет тип и название, которые совместно формируют составное название расширения, известное, как "Frankenstyle". (Слово "Frankenstayle" появилось в ходе дискуссии на Jabber-канале разработчиков, при этом оно очень понравилось всем и его начали использовать.) Тип расширения и его название задают путь к директории расширения. Тип расширения задает префикс, а имя директории является названием расширения. Ниже приведено несколько примеров:

Тип расширения Название расширения Frankenstyle Директория
mod (модуль действия) forum mod_forum mod/forum
mod (модуль действия) quiz mod_quiz mod/quiz
block (блок боковой панели) navigation block_navigation blocks_navigation
qtype (тип вопроса) shortanswer qtype_shortanswer question/type/shortanswer
quiz (отчет о тестировании) statistics quiz_statistics mod/quiz/report/statistics

Последний пример иллюстрирует то, что каждый модуль действия может объявлять типы подмодулей. На данный момент только модули действий могут делать это по двум причинам. Если бы всем расширениям было позволено иметь подрасширения, могли бы возникнуть проблемы с производительностью. Модули действий реализуют основные обучающие возможности приложения Moodle и поэтому являются самым важным типом расширений, следовательно, они имеют особые привилегии.

Пример расширения

Я смогу объяснить множество подробностей реализации архитектуры приложения Moodle, рассмотрев пример специального расширения. По традиции я решил реализовать расширение, которое будет выводить фразу "Hello world".

Это расширение на самом деле полностью не соответствует ни одному из стандартных типов расширений приложения Moodle. Это просто сценарий, не связанный с чем-либо еще, поэтому я решил остановить свой выбор на реализации "локального" ("local") расширения. Этот тип расширения является обобщенным типом для реализации разнообразных функций, которые не могут быть корректно отнесены к какому-либо из-других типов. Я назову свое расширение greet для формирования названия Frankenstyle local_greet и пути к директории local/greet. (Исходный код расширения может быть загружен.)

Каждое расширение должно содержать файл с названием version.php, который содержит некоторые основные метаданные, относящиеся к расширению. Этот файл используется системой установки расширений приложения Moodle для установки и обновления расширения. Например, файл local/greet/version.php содержит строки:

<?php
$plugin->component    = 'local_greet';
$plugin->version      = 2011102900;
$plugin->requires     = 2011102700;
$plugin->maturity     = MATURITY_STABLE;

Включение названия компонента в файл может показаться избыточным, так как оно может быть извлечено из пути к директории, но установщик использует его для проверки того, что расширение установлено в предназначенном для него месте. Поле версии содержит версию расширения. Поле maturity содержит указание на альфа-версию (ALPHA), бета-версию (BETA), релиз-кандидат (RC, release candidate) или стабильную версию (STABLE). Поле requires указывает на минимальную версию приложения Moodle, с которой совместимо данное расширение. Если это необходимо, есть возможность указать другие расширения, от которых зависит данное расширение.

Ниже приведен код главного сценария для этого простого расширения (расположенный в файле local/greet/index.php):

Строка 1: Начальная загрузка приложения Moodle

require_once(dirname(__FILE__) . '/../../config.php');        // 1

Единственной строкой этого сценария, выполняющей большую часть работы, является первая строка. Выше я упоминал о том, что файл config.php содержит данные, которые требуются приложению Moodle для соединения с базой данных и поиска директории с данными приложения Moodle. Этот файл, однако, заканчивается строкой require_once('lib/setup.php'). При использовании этого файла:

  1. загружаются все стандартные библиотеки приложения Moodle с помощью функции require_once;
  2. инициируется запуск системы обработки сессий;
  3. осуществляется соединение с базой данных; и
  4. устанавливаются значения большого количества глобальных переменных, с которыми мы столкнемся позднее.

Строка 2: Установление факта входа пользователя в систему

require_login();                                              // 2

Эта строка вынуждает приложение Moodle проверить, осуществил ли текущий пользователь вход в систему, используя любое из расширений аутентификации, настроенных администратором. Если пользователь не осуществил вход, он будет перемещен на страницу с формой ввода данных для входа в систему и эта функция никогда не вернет управление.

Сценарий, интегрированный с приложением Moodle лучшим образом, передаст большее количество аргументов для того, чтобы, скажем, установить, частью какого курса или действия является эта страница, а после этого функция require_login проверит, зарегистрирован ли пользователь в системе или наоборот, разрешен ли пользователю доступ к этому курсу и просмотр этого действия. В противном случае будет выведено соответствующее сообщение об ошибке.

13.2. Система ролей и разрешений приложения Moodle

Следующие две строки кода демонстрируют метод проверки того, что у пользователя есть разрешения на выполнение некоторых действий. Как вы можете видеть, с точки зрения разработчика, API является очень простым. На заднем плане, однако, находится усложненная система прав доступа, которая позволяет администратору осуществлять гибкий контроль над тем, что и кто имеет право делать.

Строка 3: Получение контекста

$context = context_system::instance();                        // 3

В приложении Moodle пользователи могут иметь различные права в различных местах. Например, пользователь может быть преподавателем на одном курсе и студентом на другом и, таким образом, иметь различные права в каждом из мест. Эти места называются контекстами (contexts). Контексты в приложении Moodle формируют иерархию, очень похожую на иерархию директорий в файловой системе. На верхнем уровне находится контекст системы (System context) (и, так как данный сценарий не достаточно хорошо интегрирован с приложением Moodle, он использует именно этот контекст).

В системном контексте находится некоторое количество контекстов для различных категорий, которые создаются для организации курсов. Они могут быть вложенными, поэтому категория может содержать другие категории. Контексты категорий (Category contexts) также могут содержать контексты курсов (Course contexts). Наконец, каждое действие в рамках курса имеет свой контекст модуля (Module context).

Контексты
Рисунок 13.3: Контексты

Строка 4: Проверка того, имеет ли пользователь разрешение на использование данного сценария

require_capability('local/greet:begreeted', $context);        // 4

При наличии контекста - соответствующей части приложения Moodle - разрешение может быть проверено. Каждый набор функций, который предоставляется или не предоставляется пользователю, называется возможностью (capability). Проверка возможностей позволяет реализовать более точный контроль доступа, чем базовые проверки, выполняемые с помощью функции require_login. Наш пример расширения имеет только одну возможность: local/greet:beegreeted.

Проверка осуществляется с помощью функции require_capability, которая принимает идентификатор возможности и контекст. Как и другие функции с префиксом require_..., она не вернет управление в том случае, если пользователь не имеет заданной возможности. Вместо этого она выведет ошибку. В других местах может применяться не фатальная функция has_capability, которая возвращает логическое значение, например, для установления того, показать ли ссылку на этот сценарий на другой странице.

Как администратор устанавливает то, какой пользователь имеет какие возможности? Ниже приведен список действий, которые выполняет функция has_capability (по крайней мере, концептуально):

  1. Начинается работа с действующим контекстом.
  2. Извлекается список ролей, которыми пользователь обладает в рамках данного контекста.
  3. После этого проверяется то, какими разрешениями обладает пользователь в каждой роли в рамках данного контекста.
  4. Эти разрешения объединяются для формирования финального ответа.

Описание возможностей

Как видно из примера, расширение может описывать новые возможности, соответствующие предоставляемым расширением функциям. В каждом расширении приложения Moodle присутствует поддиректория для кода с именем db. Она содержит всю информацию, требующуюся для установки или обновления расширения. Одним из файлов, хранящих эту информацию, является файл с именем access.php, который описывает возможности. Ниже приведено содержимое файла access,php из состава нашего расширения, который находится в директории local/greet/db/access.php:

<?php
$capabilities = array('local/greet:begreeted' => array(
    'captype' => 'read',
    'contextlevel' => CONTEXT_SYSTEM,
    'archetypes' => array('guest' => CAP_ALLOW, 'user' => CAP_ALLOW)
));

Здесь представлены некоторые относящиеся к данной возможности метаданные, которые используются при формировании пользовательского интерфейса для управления разрешениями. Также они используются для установления начальных разрешений для стандартных типов ролей.

Роли

Следующим элементом системы разрешений приложения Moodle являются роли. Роль (role) на самом деле представляет из себя именованный набор разрешений. После того, как вы осуществили вход в систему Moodle, вы получите роль "аутентифицированного пользователя" в рамках системного контекста, а так как системный контекст является корневым в иерархии контекстов, эта роль будет применима везде.

В рамках определенного курса вы можете быть студентом и это соответствие роли будет актуально для контекста курса и всех контекстов модулей в нем. В другом курсе, однако, вы можете выступать в другой роли. Например, Mr Gradgrind может быть преподавателем курса "Facts, Facts, Facts", но при этом быть студентом курса профессиональной разработки "Facts Aren't Everything". Наконец, пользователю может быть предоставлена роль модератора в одном определенном форуме (в рамках контекста модуля).

Разрешения

Роль описывает разрешение (permission) для каждой возможности. Например, роль преподавателя наверняка установит значение разрешения ALLOW (позволяющее использовать) возможность moodle/course:manage, а роль студента - нет. Однако, и студенту и преподавателю будет доступна возможность mod/forum:startdiscussion.

Роли обычно описываются глобально, но они могут быть описаны повторно в каждом из контекстов. Например, определенный ресурс wiki может быть открыт только для чтения для студентов с помощью изменения значения разрешения для возможности mod/wiki:edit роли студента в рамках контекста этой системы wiki (контекста модуля) на значение PREVENT (предотвращающее использование).

Существуют четыре значения разрешения:

В заданном контексте роль будет иметь одно из этих четырех значений разрешений для каждой из возможностей. Единственным различием между значениями PREVENT и PROHIBIT является то, что значение PROHIBIT не может быть изменено в подконтекстах.

Объединение разрешений

В конечном счете разрешения для всех ролей, в которых пользователь выступает в активном контексте, объединяются.

Условия использования значения PROHIBIT следующие: представьте, что пользователь создает оскорбительные сообщения на множестве форумов и нам нужно немедленно прекратить это. Мы можем создать роль Naughty, в рамках которой установить значение PROHIBIT для возможности mod/forum:post и других аналогичных возможностей. После этого мы присваиваем эту роль нарушающему порядок пользователю в системном контексте. Таким образом мы можем удостовериться в том, что этот пользователь не сможет написать ни одного сообщения ни в одном из форумов. (После этого мы свяжемся со студентом и в том случае, если получим от него удовлетворяющий нас ответ, сможем удалить соответствие этой роли, после чего он вновь сможет использовать систему.)

Таким образом, система разрешений приложения Moodle позволяет администраторам осуществлять очень гибкое управление системой. Они могут описывать желаемые роли с различными разрешениями для каждой из возможностей; они могут изменять заданные значения в рамках роли в подконтекстах; а также они могут ставить в соответствие пользователям в различных контекстах различные роли.

13.3. Вернемся к нашему примеру сценария

Следующая часть сценария иллюстрирует некоторые дополнительные функции:

Строка 5: Получение данных запроса

$name = optional_param('name', '', PARAM_TEXT);               // 5

Действием, которое должно выполнять любое веб-приложение, является получение данных запроса (переменных, передаваемых с помощью методов запросов GET или POST) с необходимыми преобразованиями для устранения уязвимости приложения к атакам на основе SQL-инъекций или межсайтового скриптинга. Приложение Moodle предоставляет два метода выполнения этого действия.

Простой метод показан выше. При его использовании извлекается значение отдельной переменной на основании имени параметра (в данном случае name), значения по умолчанию и ожидаемого типа данных. Ожидаемый тип данных используется для удаления из входящих данных всех не соответствующих ему символов. Существует множество типов, таких, как PARAM_INT, PARAM_ALPHANUM, PARAM_EMAIL и.т.д.

Существует также функция, аналогичная функции required_param, которая также, как и все функции семейства require_... прекращает выполнение сценария и выводит сообщение об ошибке в том случае, если ожидаемый параметр не обнаружен.

Другим механизмом, поддерживаемым приложением Moodle и предназначенным для получения данных запроса является использование полнофункциональной библиотеки для работы с формами. Этот способ реализуется благодаря коду совместимости с библиотекой HTML QuickForm из PEAR. (Для разработчиков, не имеющих опыта работы с языком PHP, следует отметить, что PEAR является эквивалентом хранилища CPAN для языка PHP.) Эта библиотека казалась хорошим выбором во время поиска подходящего решения, но ее поддержка на сегодняшний день не осуществляется. В какой-то момент в будущем нам придется перейти к использованию новой библиотеки для работы с формами, чего ожидают многие из нас, так как библиотека QuickForm имеет несколько раздражающий архитектурных недостатков. На сегодняшний день, однако, она соответствует предъявляемым требованиям. Формы могут быть описаны как коллекция полей различных типов (т.е. текстовых полей, выпадающих списков для выбора элементов, полей для выбора дат) с проверкой данных на стороне клиента и сервера (включая использование описанных типов, использующих префикс PARAM_...).

Строка 6: Глобальные переменные

if (!$name) {
    $name = fullname($USER);                                  // 6
}

Этот фрагмент кода иллюстрирует первую глобальную переменную из набора глобальных переменных приложения Moodle. Переменная $USER позволяет получить информацию о пользователе, получившим доступ к данному сценарию. Другие глобальные переменные:

а также некоторые другие переменные, с некоторыми из которых мы будем иметь дело ниже.

Вы могли прочитать слова "глобальная переменная" с ужасом. Однако, следует заметить, что язык PHP обрабатывает единственный запрос в каждый момент времени. Следовательно, эти переменные не настолько глобальны. Фактически глобальные переменные языка PHP могут рассматриваться как реализация шаблона проектирования реестра уровня потока (обратитесь к книге Martin Fowler Patterns of Enterprise Application Architecture) и это тот метод, в соответствии с которым приложение Moodle использует эти переменные. Очень удобной чертой этого подхода является тот факт, что он делает доступными часто используемые объекты в коде без необходимости передачи их каждой функции и методу. Использованием этого подхода крайне редко злоупотребляют.

Все не так просто

Эта строка также позволяет описать проблемную область кода: не все так просто. Для вывода имени пользователя нужно применить более сложный подход, чем простое объединение строк $USER->firstname, '~' и $USER->lastname. В школе может действовать распоряжение о выводе любой из этих частей, а также различные культуры используют различные соглашения о том, в каком порядке выводить составные части имени пользователя. Следовательно, существует ряд переменных конфигурации и функция для объединения частей имени в соответствии с этими правилами.

Та же проблема актуальна и для дат. Различные пользователи могут находиться в различных часовых поясах. Приложение Moodle хранит все даты в формате меток времени Unix, которые являются целочисленными значениями и, следовательно, совместимы со всеми базами данных. Существует функция userdate для вывода метки времени пользователю, использующему соответствующие настройки часового пояса и локализации.

Строка 7: Журналирование

add_to_log(SITEID, 'local_greet', 'begreeted',
        'local/greet/index.php?name=' . urlencode($name));    // 7

Все важные операции в рамках приложения Moodle подлежат журналированию. Журналы событий сохраняются в таблице базы данных. Это компромисс. Этот подход позволяет производить подробный анализ журнала достаточно просто, к тому же различные отчеты, сформированные на основе журналов событий, включены в комплект поставки приложения Moodle. Однако на сайте большого размера с большим количеством посетителей этот подход приводит к проблемам с производительностью. Таблица журнала событий увеличивается в размере, затрудняет резервное копирование базы данных и замедляет выполнение запросов данных. Также при записи данные в таблице могут объединяться. Эти проблемы могут быть преодолены различными путями, например, с помощью объединения операций записи или путем архивирования или удаления устаревших записей для перемещения их из основной базы данных.

13.4. Генерация результирующего документа

Процесс генерации результирующего документа в большей степени зависит от двух глобальных объектов.

Строка 8: Глобальная переменная $PAGE

$PAGE->set_context($context);                                 // 8

Переменная $PAGE хранит информацию о странице, которая должна быть сгенерирована. Эта информации впоследствии доступна для кода, генерирующего результирующий HTML-документ. Для работы этого сценария требуется явное указание используемого контекста. (В других случаях контекст может быть установлен автоматически с помощью функции require_login.) Строка URL для этой страницы также должна быть явно задана. Это может показаться избыточным, но обоснование требования этих данных заключается в том, что вы можете перейти на определенную страницу, используя любое количество различных строк URL, но строка URL, передаваемая функции set_url должна быть канонической строкой URL для страницы - качественной постоянной ссылкой, если вам угодно. Заголовок страницы также устанавливается. Этот процесс заканчивается генерацией элемента head документа HTML.

Строка 9: Строка URL приложения Moodle

$PAGE->set_url(new moodle_url('/local/greet/index.php'),
        array('name' => $name));                              // 9

Я хотел бы обратить внимание на этот замечательный небольшой вспомогательный класс, который позволяет значительно упростить манипуляции со строками URL. Отдельно следует вспомнить о том, что функция add_to_log, вызванная выше, не использует этот вспомогательный класс. В самом деле, API журналирования не может принимать объекты moodle_url. Этот тип несовместимости является типичным признаком значительного возраста кодовой базы приложения, такого, как у кодовой базы приложения Moodle.

Строка 10: Интернационализация

$PAGE->set_title(get_string('welcome', 'local_greet'));       // 10

Приложение Moodle использует свою собственную систему для перевода интерфейса на любой из языков. На сегодняшний день может быть доступно большое количество библиотек интернационализации для языка PHP, но в 2002 году, когда приложение было впервые реализовано, не было доступно ни одной подходящей библиотеки. Система формируется вокруг функции get_string. Строки идентифицируются с помощью ключа и названия расширения в формате Frankenstyle. Как можно увидеть в строке 12, возможно преобразовать значения в строку. (Множества значений обрабатываются с использованием массивов объектов языка PHP.)

Поиск строк осуществляется в языковых файлах, содержащих простые массивы языка PHP. Ниже приведен языковой файл local/greet/lang/en/local_greet.php для нашего расширения:

<?php
$string['greet:begreeted'] = 'Be greeted by the hello world example';
$string['welcome'] = 'Welcome';
$string['greet'] = 'Hello, {$a}!';
$string['pluginname'] = 'Hello world example';

Следует отметить то, что наряду с двумя строками, используемыми в нашем сценарии, в файле также присутствуют строки для указания названия возможности и названия расширения, которые отобразятся в пользовательском интерфейсе.

Различные языки идентифицируются с помощью двухбуквенных кодов стран (в данном случае en). Языковые пакеты могут происходить от других языковых пакетов. Например, fr_ca (французский канадский) языковой пакет декларирует fr (французский) в качестве родительского языка и, таким образом, задает только те строки, которые отличаются от строк французского языка. Так как разработка приложения Moodle началась в Австралии, код en относится к британскому английскому языку, а языковой пакет en_us (американский английский) происходит от него.

Снова следует упомянуть о том, что простой API на основе функции get_string для разработчиков расширений скрывает большую часть сложностей, включая установление языка (который может зависеть от действующих пользовательских настроек или настроек определенного курса, который изучается в данный момент) и поиск среди всех языковых пакетов и родительских языковых пакетов с целью получения строки.

Создание файлов языковых пакетов и координация переводов осуществляется с помощью ресурса http://lang.moodle.org/, который использует приложение Moodle со специальным расширением (local_amos). Этот ресурс использует систему контроля версий Git и базу данных в качестве системы для хранения языковых файлов вместе с полной историей версий.

Строка 11: Начало операции вывода страницы

echo $OUTPUT->header();                                       // 11

Это еще одна безобидно выглядящая строка, которая делает гораздо больше, чем кажется. Дело в том, что перед выполнением любой операции вывода страницы должна быть выбрана подходящая тема (оболочка). Этот процесс может зависеть от комбинации контекста страницы и настроек пользователя. Значение переменной $PAGE->context, однако, было установлено в строке 8, поэтому глобальная переменная $OUTPUT не будет инициализирована в начале сценария. В общем случае, решение этой проблемы заключается в использовании особенности языка PHP для создания соответствующего объекта $OUTPUT на основе информации, получаемой из переменной $PAGE в первый момент вызова любого метода для вывода страницы.

Еще одной важной особенностью является то, что каждая страница приложения Moodle может содержать блоки (blocks). Эти элементы являются дополнительными настраиваемыми частями содержимого страницы, которые обычно выводятся слева или справа от основного содержимого страницы. (Они являются своего рода расширениями.) Снова следует упомянуть о том, что определенный набор выводимых блоков зависит от гибко изменяемых параметров (которые могут контролироваться администратором) контекста страницы и других аспектов идентичности страницы. Следовательно, следующим шагом подготовки к выводу страницы является вызов функции $PAGE->blocks->load_blocks().

Как только вся необходимая информация обработана, расширение тем (которое в полной степени контролирует внешний вид страницы) вызывается для генерации страницы, включая стандартные заголовочные и завершающие области страницы, если это необходимо. Этот вызов также отвечает за добавление данных блоков в необходимое место результирующего документа формата HTML. В середине выводимой страницы должен находиться тэг div, в котором будет находиться специфическое содержимое этой страницы. После генерации документа в формате HTML он разделяется на две части по тегу div, находящемуся перед началом основного содержимого. Первая часть возвращается, а вторая - сохраняется для последующего возврата с помощью функции $OUTPUT->footer().

Строка 12: Операция вывода основной части страницы

echo $OUTPUT->box(get_string('greet', 'local_greet',
        format_string($name)));                               // 12

В этой строке производится вывод основной части страницы. В данном случае просто выводится сообщение приветствия. Приветствие, повторюсь, является локализованной строкой, в данном случае со значением, подставляемым в предназначенное для него поле. Основной объект вывода $OUTPUT предоставляет множество таких полезных методов, как box для описания требуемых выводимых элементов с помощью высокоуровневых директив. Различные темы могут контролировать то, какой именно код HTML будет использован для вывода сообщения.

Данные, изначально полученные из переменной пользователя ($name) выводятся после обработки с помощью функции format_string. Это еще одно мероприятие для защиты от XSS-атак. Эта функция также позволяет пользователю применять фильтры текста (другой тип расширения). Примером фильтра может быть фильтр LaTeX, который заменяет такие входные данные, как $$x + 1$$ на изображение математического действия. Я упомяну, но не буду подробно описывать то, что на самом деле существуют три различных функции (s, format_string и format_text), применяемые в зависимости от определенного выводимого типа содержимого страницы.

Строка 13: Завершение операции вывода страницы

echo $OUTPUT->footer();                                       // 13

Наконец осуществляется вывод завершающей части страницы. Этот пример не иллюстрирует данную операцию, но приложение Moodle отслеживает все сценарии на языке JavaScript, необходимые для функционирования страницы, и выводит все необходимые тэги для подключения сценариев в завершающей части страницы. Это стандартная и разумная практика. Она позволяет пользователям видеть страницу без ожидания загрузки всех сценариев на языке JavaScript. Разработчик должен подключать сценарии на языке JavaScript с помощью таких вызовов API, как $PAGE->requires->js('/local/greet/cooleffect.js').

Приводит ли эта практика к смешению логики и выводимых данных

Очевидно, что размещение выводимого кода в файле сценария index.php даже при высоком уровне абстракции ограничивает гибкость управления выводимыми данными с помощью тем. Это является еще одним признаком значительного возраста кодовой базы приложения Moodle. Глобальная переменная $OUTPUT была представлена в 2010 году как переходный этап в рамках мероприятий по отказу от устаревшего кода, в котором функции вывода и управления содержимым страницы были размещены в одном файле и перехода к архитектуре, в которой весь код вывода страницы был корректно отделен. Это также иллюстрирует неудачный способ генерации страницы, ее разделения на две части, после чего выводимые сценарием данные могут быть размещены между заголовочной и завершающей областью страницы. Как только код вывода страницы был выделен из описанного сценария и перенесен в сценарий, который называется сценарием вывода страницы приложения Moodle, темы получили возможность осуществлять полную (или частичную) замену функций кода вывода страницы для рассматриваемого сценария.

Небольшой рефакторинг может позволить переместить код вывода страницы из сценария index.php в сценарий вывода страницы. Завершающие строки сценария index.php (строки с 11 по 13) изменятся на:

$output = $PAGE->get_renderer('local_greet');
echo $output->greeting_page($name);

и появится новый файл local/greet/renderer.php:

<?php
class local_greet_renderer extends plugin_renderer_base {
    public function greeting_page($name) {
        $output = '';
        $output .= $this->header();
        $output .= $this->box(get_string('greet', 'local_greet', $name));
        $output .= $this->footer();
        return $output;
    }
}

Если теме необходимо полностью изменить этот вывод, она может объявить подкласс этого класса, в котором будет повторно объявлен метод greeting_page. Функция $PAGE->get_renderer() устанавливает подходящий класс вывода страницы для ее вывода в зависимости от используемой темы. Следовательно, код вывода (показа) страницы полностью отделен от кода управления из файла index.php и проведено усовершенствование расширения с уровня использования устаревшего кода Moodle до уровня использования архитектуры MVC ("модель-представление-поведение" - "model-view-controller").

13.5. Абстракция для работы с базой данных

Сценарий "Hello world" был достаточно простым, поэтому у меня не было необходимости в получении доступа к базе данных, но несмотря на это, некоторые используемые библиотечные вызовы приложения Moodle осуществляли запросы к базе данных. Ниже я кратко опишу уровень абстракции для работы с базой данных приложения Moodle.

Приложение Moodle использовало библиотеку ADOdb в качестве основы уровня абстракции для доступа к базе данных, но мы столкнулись с трудностями во время ее использования, кроме того дополнительный уровень кода библиотеки оказывал существенное влияние на производительность. Из-за этого в версии 2.0 приложения Moodle мы перешли к использованию нашего собственного уровня абстракции, являющегося тонкой прослойкой между различными библиотеками для работы с базами данных языка PHP.

Класс moodle_database

Сердцем библиотеки является класс moodle_database. Он описывает интерфейс, предоставляемый глобальной переменной $DB, который позволяет осуществлять доступ к соединению к базой данных. Типичный пример использования:

$course = $DB->get_record('course', array('id' => $courseid));

Этот вызов переводится на язык SQL следующим образом:

SELECT * FROM mdl_course WHERE id = $courseid;

и возвращает данные в форме обычного объекта языка PHP с общедоступными полями, поэтому вы можете получать доступ к ним: $course->id, $course->fullname, и.т.д.

Такие простые методы, как этот, используются для простых запросов и простых обновлений и добавлений данных в базу. Иногда необходимо выполнить более сложные SQL-запросы, например, для формирования отчетов. Для этого случая существуют методы выполнения произвольных SQL-запросов:

$courseswithactivitycounts = $DB->get_records_sql(
   'SELECT c.id, ' . $DB->sql_concat('shortname', "' '", 'fullname') . ' AS coursename,
        COUNT(1) AS activitycount
   FROM {course} c
   JOIN {course_modules} cm ON cm.course = c.id
   WHERE c.category = :categoryid
   GROUP BY c.id, c.shortname, c.fullname ORDER BY c.shortname, c.fullname',
   array('categoryid' => $category));

Некоторые аспекты, которые следует учесть в данном случае:

Описание структуры базы данных

Другой областью, в которой системы управления базами данных значительно отличаются, является синтаксис языка SQL для описания таблиц. Для преодоления этой проблемы каждое расширение приложения Moodle (и ядро Moodle) описывает требуемые таблицы базы данных в файле формата XML. Система установки приложения Moodle производит разбор файлов install.xml и использует информацию из них для создания требуемых таблиц и индексов. Существует встроенный в приложение Moodle инструмент разработчика под названием XMLDB, который может помочь в создании и редактировании этих установочных файлов.

Если структура базы данных должна быть изменена между двумя релизами приложения Moodle (или расширения) разработчик ответственен за написание кода (с использованием дополнительного объекта базы данных, предоставляющего методы языка описания данных DDL) для обновления структуры базы данных с сохранением пользовательских данных. Следовательно, приложение Moodle будет всегда самостоятельно обновляться от одного релиза к другому, упрощая операции обслуживания для администраторов.

Одним спорным моментом, обусловленным тем, что приложение Moodle начало свое развитие с использовании версии 3 MySQL, является то, что база данных приложения Moodle не использует внешние ключи. Такое положение вещей приводит к тому, что ошибки остаются не обнаруженными даже с учетом того, что современные базы данных позволяют обнаружить проблему. Сложностью является то, что люди используют приложение Moodle на своих сайтах без внешних ключей в течение многих лет, поэтому почти наверняка в базах присутствуют несвязанные данные. Добавление ключей на данный момент будет невозможным без очень сложной работы по очистке баз данных. Несмотря на это, с момента включения в состав приложения Moodle версии 1.7 системы XMLDB (в 2006 году!) файлы install.xml содержат описания внешних ключей, которые должны присутствовать в базе, и мы все еще надеемся когда-нибудь выполнить всю необходимую работу для того, чтобы добавлять эти ключи в ходе процесса установки.

13.6. Что не было описано

Я надеюсь, я что мне удалось представить неплохой обзор принципа работы приложения Moodle. Ввиду ограничения объема главы я исключил из рассмотрения несколько интересных тем, включая темы о том, как расширения аутентификации, регистрации и оценок позволяют приложению Moodle взаимодействовать с информационными системами для хранения данных студентов, а также об интересном способе хранения загружаемых файлов приложением Moodle на основе их содержимого. Информация, касающаяся этих и других аспектов архитектуры Moodle может быть найдена в документации для разработчиков.

13.7. Выученные уроки

Один из интересных аспектов работы над проектом Moodle заключается в том, что он получил развитие в качестве исследовательского проекта. Moodle позволяет (но не заставляет) использовать подход социальной конструктивистской педагогики. То есть, мы лучшим образом учимся создавая что-то, а также учимся друг у друга при работе в сообществе. Вопрос доктора философии Martin Dougiamas по поводу проекта заключался не в том, эффективна ли эта модель для обучения, а в том, эффективна ли эта модель для развития проекта с открытым исходным кодом. То есть, можем ли мы рассматривать проект Moodle как попытку изучения возможности создания и использования виртуального образовательного окружения (VLE), причем эта попытка заключалась в непосредственном создании и использовании приложения Moodle сообществом, в котором преподаватели, разработчики, администраторы и студенты преподают и учатся друг у друга? Я считаю эту модель удачной для проекта разработки программного продукта с открытым исходным кодом. Основным местом встречи разработчиков и пользователей для взаимного обучения являются дискуссии в разделе форумов проекта Moodle, а также раздел базы данных ошибок.

Возможно, наиболее важным результатом этого исследовательского проекта является вывод о том, что вы не должны бояться начинать разработку с реализации наиболее простого возможного решения в первую очередь. Например, в ранних версиях приложения Moodle имелось только несколько жестко заданных ролей, таких, как преподаватель, студент и администратор. Этого было достаточно в течение многих лет, но в итоге на ограничения было обращено внимание. Когда пришло время проектирования системы ролей для приложения Moodle версии 1.7, у сообщества был большой опыт использования Moodle, а также большое количество запросов функций, которые указывали на то, что нужно людям для более гибкого управления системой доступа. Все это помогло в проектировании такой простой системы ролей, как это возможно, но при этом и такой сложной, как это необходимо. (Фактически первая версия системы ролей была чрезмерно сложной, поэтому впоследствии она была немного упрощена в версии 2.0 приложения Moodle.)

Если вы рассматриваете программирование как деятельность по решению задач, вам может показаться, что в первый раз для приложения Moodle была выбрана неподходящая архитектура, а позднее пришлось потерять много времени на ее корректировку. Я могу сказать, что такая точка зрения неконструктивна при попытке решения сложных задач, встречающихся в реальной жизни. Во время начала разработки приложения Moodle никто не располагал в достаточной степени знаниями о том, как спроектировать используемую на данный момент систему ролей. С точки зрения обучающегося, различные ступени развития, пройденные приложением Moodle до достижения актуальной архитектуры, были необходимы и неизбежны.

При таком подходе к разработке возможно изменение практически любого аспекта системной архитектуры, как только вы узнаете больше о ней. Мне кажется, приложение Moodle показывает, что это возможно. Например, мы нашли способ способ последовательного рефакторинга кода для перехода от устаревших сценариев к архитектуре MVC. Это требует усилий, но, кажется, что тогда, когда это необходимо, ресурсы для реализации этих изменений могут быть найдены в сообществах проектов с открытым исходным кодом. С точки зрения пользователя, система последовательно развивается с каждым релизом.

14.1. Почему высокоэффективные параллельные вычисления так важны?

В настоящее время Интернет имеет настолько широкое и повсеместное распространение, что сложно себе представить его отсутствие каких-нибудь десять лет назад. Это результат взрывного развития от перехода по ссылкам между текстами с разметкой HTML, основанных на NCSA, а затем и на web-серверах Apache, до 2 миллардов пользователей по всему миру, находящихся всегда на связи. С широким распространением постоянно подключённых к сети персональных компьютеров, мобильных устройств, а теперь и планшетных компьютеров, облик Интернета быстро меняется и целые экономики становятся цифровыми и проводными. Онлайн-сервисы стали более сложными с явным уклоном в сторону мгновенного доступа к постоянно обновляющейся информации и развлечениям. Вопросы обеспечения безопасного ведения бизнеса в Интернете также сильно изменились. Соответственно, web-сайты сейчас гораздо сложнее, чем раньше, и требуют больше инженерных решений для повышения надёжности и масштабируемости.

Одной из самых больших проблем при выборе архитектуры web-сайта всегда был параллелизм. С момента появления web-сервисов необходимость в одновременном выполнении различных операций постоянно растёт. Необходимость одновременного обслуживания популярными web-сайтами сотен тысяч или даже миллионов пользователей - вовсе не редкость. Десятилетие назад главной причиной параллелизма была медленная скорость клиентов, использующих ADSL- или dial-up-соединения. В настоящее время параллелизм обусловлен сочетанием мобильных клиентов и новой архитектуры приложений, которые обычно основываются на поддержании постоянного подключения, что позволяет клиенту получать свежие новости, твиты, новостные ленты друзей и т.п. Ещё одним важным фактором, приведшим к увеличению важности параллелизма, стало изменение поведения современных браузеров, которые открывают от 4 до 6 соединений к web-сайту для ускорения загрузки страницы.

Чтобы проиллюстрировать проблему медленных клиентов представьте себе простой web-сервер на основе Apache, который генерирует относительно короткий ответ размером 100 КБ - web-страницу с текстом или изображением. Генерация страницы и ответ на запрос могут занять доли секунды, но занимают 10 секунд из-за скорости передачи клиенту, имеющему скорость доступа 80 Кбит/с (10 КБ/с). То есть, web-сервер будет сравнительно быстро подготавливать содержимое страницы в 100 КБ и затем оставаться занятым ещё 10 секунд из-за медленной передачи клиенту. А теперь представьте, что у вас есть 1000 подключённых клиентов, которые одновременно запросили получение аналогичных страниц. Если на каждого клиента выделять всего по 1 МБ оперативной памяти, то это привело бы к необходимости выделения 1000 МБ (около 1 ГБ) оперативной памяти для передачи 100 КБ информации. В реальности, типовой web-сервер на базе Apache обычно выделяет более 1 МБ на одно соединение, а эффективная скорость мобильных клиентов, к сожалению, десятки кбит/с. Ситуация с отправкой информации медленным клиентам может быть в какой-то степени улучшена путём увеличения буферов обмена сокетов ядра операционной системы, но это не универсальное решение и может иметь нежелательные побочные эффекты.

С постоянно поддерживаемыми соединениями проблема параллелизма при обработке становится ещё более выраженной, так как клиенты с целью уменьшения времени на подключение не разрывают ранее установленные соединения и web-сервер вынужден для каждого подключённого клиента резервировать определённый объём памяти.

Следовательно, чтобы справиться с возросшей нагрузкой, связанной с увеличивающейся аудиторией и, как следствие, требованиями к параллелизму, а также справляться с этим и далее, web-сайт должен основываться на ряде очень эффективных "строительных" блоков. Другие части этого уравнения, такие как аппаратная часть (ЦПУ, ОЗУ, НЖМД), пропускная способность сети, системное программное обеспечение и архитектура системы хранения, очень важны, но именно программное обеспечение web-сервера принимает и обрабатывает соединения клиентов. То есть, web-сервер должен быть способен к нелинейному масштабированию с ростом числа одновременных соединений и количества запросов в секунду.

Apache не подходит?

Apache - это доминирующий сейчас в Интернете web-сервер, берущий начало в 1990-х годах. Первоначально его архитектура соответствовала операционным системам и аппаратному обеспечению того времени, соответствовала и состоянию Интернета, где было принято для каждого web-сайта выделять физический сервер с единственным экземпляром Apache. К началу 2000-х годов стало очевидно, что модель с выделенным web-сервером не может удовлетворять растущие потребности web-сервисов. Несмотря на прочную основу для будущего развития, заложенную в Apache, его архитектура предусматривала запуск своей копии для каждого нового соединения, что не подходило для нелинейной масштабируемости web-сайта. В итоге Apache стал web-сервером общего назначения и сосредоточился на увеличении количества разнообразных функций и сторонних расширений для обеспечения универсальной применимости к практически любому виду web-разработки. Однако, за всё необходимо платить: столь богатый и универсальный инструментарий в рамках одной программы снижает её возможности к масштабированию из-за увеличенного использования ЦПУ и памяти на каждое соединение.

Таким образом, аппаратная часть сервера, его операционная система и сетевые ресурсы перестали быть основной проблемой для развития web-сайта, что привело web-разработчиков по всему миру к поиску более эффективных средств организации web-серверов. Около десяти лет назад Daniel Kegel, ведущий разработчик программного обеспечения, объявил, что "настало время, когда web-серверы должны обрабатывать десять тысяч клиентских соединений одновременно" и предложил называть всё это облачными сервисами Интернета. Манифест "C10K" Кегеля (Daniel Kegel) обострил проблему оптимизации web-серверов для одновременной поддержки большого числа клиентских соединений и nginx при таком подходе оказался одним из лучших.

С целью преодоления проблемы C10K в 10'000 одновременных соединений в nginx была заложена другая архитектура, подразумевающая лучшую нелинейную масштабируемость, как по числу поддерживаемых одновременных соединений, так и по числу запросов в секунду. nginx основан на событийно-ориентированной модели (event-based), что позволяет ему не порождать новый процесс или поток для каждого запроса web-страницы, как это делает Apache. В результате возрастание нагрузки стало более равномерным, а использование ресурсов памяти и ЦПУ управляемым. nginx мог теперь обрабатывать десятки тысяч одновременных соединений на сервере с обычной аппаратной частью.

С выходом первой версии nginx стало понятно, что он должен применяться совместно с Apache для выдачи поддерживаемой nginx статической информации вроде HTML, CSS, JavaScript и изображений, что позволяло снизить нагрузку и время отклика серверов приложений на базе Apache. В ходе развития в nginx были добавлены интеграция с приложениями с помощью FastCGI, uswgi или SCGI протоколов и системами распределённого кеширования в оперативной памяти, такими как memcached. Также были добавлены другие полезные функции, такие как обратный прокси-сервер с балансировкой и кешированием. Эти дополнительные возможности превратили nginx в эффективный набор инструментов для построения масштабируемой web-инфраструктуры.

В феврале 2012 года был представлен релиз Apache 2.4.x. Несмотря на добавление в эту версию Apache новых модулей с ядром многопоточной обработки и прокси-сервером, направленными на повышение масштабируемости и производительности, ещё прошло слишком мало времени, чтобы говорить о соизмеримых производительности, параллелизме обработки и экономном использовании ресурсов в сравнении с web-сервером, изначально построенным на событийно-ориентированной модели. Было бы очень приятно увидеть лучшую масштабируемость в новой версии сервера приложений Apache, хотя и не понятно, как это поможет устранить узкие места на серверной стороне в типовых web-конфигурациях nginx + Apache.

Есть ли ещё преимущества при использовании nginx?

Обработка большого количества одновременных запросов с обеспечением высокой производительности и эффективности всегда была ключевым преимуществом при внедрении nginx. Однако, есть и другие не менее интересные преимущества.

В последние несколько лет web-архитекторы восприняли идею удаления зависимостей и отделения инфраструктры приложений от web-сервера. Однако, то, что раньше существовало в виде web-сайта, основанного на LAMP (Linux, Apache, MySQL, PHP или Perl), теперь может не только основываться на LEMP ("E" означает "enginx"), но и всё чаще встречается в виде web-сервера с интегрированной инфраструктурой или на том же наборе приложений и баз данных, но взаимодействующих на иных принципах.

nginx очень хорошо подходит для этого, так как обеспечивает весь необходимый функционал: снижение нагрузки при обработке одновременных запросов, снижение времени отклика, поддержка SSL (Secure Socket Layer), работа со статической информацией, сжатие и кеширование, управление отказом обслуживания соединений и запросов и даже HTTP-потоковое вещание мультимедийной информации - всё это делает nginx более эффективным для применения в качестве первого принимающего запорсы web-сервера. Он также реализует непосредственную интеграцию с memcached/Redis или другими NoSQL-решениями для повышения производительности при одновременной обработке большого количества пользователей.

С появлением и широким распроранением различных языков программирования и комплектов разработчика всё большее и большее количество компаний начинает их применять, что приводит к изменению способов разработки и внедрения. nginx стал одним из наиболее важных компонентов этих меняющихся парадигм и уже помог многим компаниям создать и развивать свои web-сервисы быстро и в рамках запланированных бюджетов.

Первая строка исходного кода nginx была написана в 2002 году. В 2004 году он был выпущен под лицензией "BSD 2-Clause License". С тех пор количество пользователей nginx постоянно растёт, предлагаются новые идеи, представляются отчёты об ошибках, формируются предложения и замечания - всё это вместе чрезвычайно полезно и выгодно для всего сообщества.

Исходный код nginx является оригинальным и был полностью написан "с нуля" на языке программирования Си. nginx был портирован на множество архитектур и операционных систем, включая Linux, FreeBSD, Solaris, Mac OS, AIX и Microsoft Windows. nginx основывается на своих собственных библиотеках и стандартных модулях, имеющих очень мало внешних зависимостей помимо библиотеки языка C, за исключением zlib, PCRE и OpenSSL, которые могут быть исключены во время сборки при необходимости или по лицензионным соображениям.

И ещё несколько слов о Windows-версии nginx. nginx работает в среде Windows. Причём Windows-версия nginx больше похожа на доказательство правильно выбранной концепции, а не на полнофункциональную портированную версию. Есть определённые ограничения nginx, связанные с архитектурой ядра Windows и отсутствием на текущее время хорошего взаимодействия с ним. Известные проблемы Windows-версии nginx это: значительно меньшее число поддерживаемых одновременных соединений, более низкая производительность, отсутствие кеширования и отсутствие управления полосой пропускания. Функционал будущих Windows-версий nginx будет более полно соответствовать основной версии.

Вы сможете оценить статью и оставить комментарий, если войдете или зарегистрируетесь.

15.1. Введение

Open MPI [GFB +04] - это программная реализация стандарта интерфейса передачи сообщений (MPI) с открытым исходным кодом. Для того, чтобы можно было рассматривать архитектуру и внутреннюю организацию Open MPI, нужно немного обсудить стандарт MPI.

Интерфейс передачи сообщений (MPI)

Стандарт MPI создан и поддерживается форумом MPI Forum - открытой группой, состоящей из экспертов по параллельным вычислениям, причем как из производственных, так и из научных кругов. В стандарте MPI определяется интерфейс API, который используется для переносимого высокопроизводительного межпроцессного взаимодействия (IPC) определенного типа: передачи сообщений. В частности, в документе MPI описывается надежная передача дискретных сообщений между процессами MPI. Хотя определение «процесс MPI» подлежит некоторой интерпретации для конкретной платформы, он, как правило, соответствует концепции процесса операционной системы (например, процесса POSIX). Интерфейс MPI специально предназначен реализации в среднем слое, что означает, что приложения, находящиеся выше, вызывают функции MPI для передачи сообщений.

MPI определяет высокоуровневый интерфейс API, что означает, что в нем абстрагируются от всех лежащих ниже транспортных механизмов, используемых при передаче сообщений между процессами. Идея состоит в том, чтобы процесс X, отправляющий сообщение, мог, по сути, сказать следующее: "берем этот массив из 1073 значений двойной точности и отправляем его процессу Y". Соответствующий процесс Y, получающий сообщение, по сути, мог сказать: "получаем массив из 1073 значений двойной точности от процесса X". Происходит чудо и массив из 1073 значений двойной точности поступает в ожидающий его буфер в процессе Y.

Обратите внимание на то, чего нет в этом обмене: нет такого понятия, как создание подключения, нет потока байтов, который нужно интерпретировать и нет сетевых адресов, используемых при обмене. Интерфейс MPI, абстрагируясь от всего этого, не только скрывает всю эту сложность от приложения, находящегося на более высоком уровне, но и делает приложение переносимым в другие среды и на другие транспортные уровни, осуществляющие передачу сообщений. В частности, правильное приложение MPI является совместимым по исходному коду в широком спектре платформ и типов сетей.

В интерфейсе MPI определяется не только соединение типа «точка-точка» (например, отправка и получение сообщения), в нем также определяются другие шаблоны соединения, например, коллективные (collective) соединения. Коллективными операциями являются такие, при которых в одном действии коммуникации участвуют несколько процессов. Например, надежное широковещательная передача данных (broadcast), когда в начале операции сообщение есть у одного процесса, а в конце операции это сообщение есть у всех процессов в группе. В MPI также определены другие концепции и шаблоны коммуникации, которые здесь не описываются. На момент написания статьи последней версией стандарта MPI был стандарт MPI-2.2 [For09]. Также были опубликованы черновые версии нового стандарта MPI-3; он должен быть опубликован уже в конце 2012 года. Прим. пер.: эта версия была опубликована 21 сентября 2012 года.

Использование MPI

Есть много реализаций стандарта MPI, в которых поддерживается широкий спектр различных платформ, операционных систем и типов сетей. В некоторых реализациях открытый исходный код, некоторые из них - закрытый. Open MPI, как следует из названия, является одной из реализаций с открытым исходным кодом. К числу типичных транспортных сетей MPI относятся следующие (но ими не ограничиваются): различные протоколы поверх Ethernet (например, TCP, iWARP, UDP, сами фреймы Ethernet и т.д.), совместно используемая память и InfiniBand.

Реализации MPI обычно используются в так называемых средах «высокопроизводительных вычислений» (HPC). Интерфейс MPI, в сущности, предоставляет соединения типа IPC для программ моделирования, вычислительных алгоритмов и других приложений типа «больших числовых молотилок».Для входных данных, с которыми работают эти приложения, обычно требуется выполнять слишком большой объем вычислений с тем, чтобы можно было ограничиться только одним сервером; задания MPI распределены по десяткам, сотням, а то и тысячам серверов, причем для того, чтобы решить одну вычислительную задачу, все они работают сообща.

Это означает, что приложения, использующие MPI, являются по своей сути параллельными и требуют высокой вычислительной мощности. Нет ничего необычного в том, что все ядра процессора при выполнении задания MPI работают на 100%. Для ясности — задания MPI обычно выполняются в специализированных средах, где процессы MPI являются единственным приложением, запущенным на машине (конечно, в дополнение к минимальным функциональным возможностям операционной системы).

Таким образом, реализации MPI, как правило, ориентированы на обеспечение чрезвычайно высокой производительности, измеряемой такими показателями, как:

Open MPI

Первая версия стандарта MPI - MPI-1.0 была опубликована в 1994 г. [Mes93]. Версия MPI-2.0, являющаяся набором дополнений поверх стандарта MPI-1, была завершена в 1996 г. [GGHL +96].

В первые десять лет после публикации стандарта MPI-1 количество различных реализаций MPI увеличилось. Некоторые из них представлялись поставщиками для своих собственных средств межсетевых соединений. Другие реализации возникли в среде исследовательских и академических сообществ. Такие реализации были по качеству типично «исследовательскими», что означает, что их целью было изучение различных концепций высокопроизводительных сетей и представление доказательств концепций правильности их работы. Тем не менее, некоторые из них были достаточно высокого качества, так что они завоевали популярность и привлекли некоторое количество пользователей.

Open MPI представляет собой объединение четырех исследовательских/академических реализаций MPI с открытым исходным кодом: LAM/MPI, LA/MPI (Лос-Аламосский вариант MPI) и FT-MPI (отказоустойчивый вариант MPI). Вскоре после создания группы Open MPI к ней присоединилась команда проекта PACX-MPI.

Когда к нам пришло коллективное осознание того, что, кроме незначительных различий в оптимизации и возможностях, наши варианты кода программ кода были очень похожи, представители этих четырех команд разработчиков решились на сотрудничество. Каждый из четырех вариантов кода имел свои сильные и слабые стороны, но в целом, они делали более или менее одно и то же. Так зачем конкурировать? Почему бы не объединить наши ресурсы, работать вместе, и сделать еще лучшую реализацию MPI?

После долгих обсуждений было принято решение отказаться от наших четырех уже существующих вариантов кода и взять из предыдущих проектов только лучшие идеи. Это решение было обусловлено главным образом следующими соображениями:

Таким образом, проявился проект Open MPI. Первый раз он был помещен в Subversion 22 ноября 2003 года.

15.2. Архитектура

По целому ряду причин (в основном, связанных либо с производительностью, либо с переносимостью) единственными двумя возможностями языка первичной реализации были C и C++. Язык C++ в конце концов был отвергнут, поскольку различные компиляторы С++, как правило, размещали структуры/классы в памяти в соответствии с различными алгоритмами оптимизации, что при работе с сетью приводило к различным реализациям. Поэтому в качестве основного языка реализации был выбран язык C, что оказало влияние на несколько архитектурных проектных решений.

Когда проект Open MPI был запущен, мы знали, что он должен представлять собой сложный код большого объема:

Поэтому на разработку архитектуры мы потратили много времени, причем сосредоточивались на следующих трех аспектах:

  1. Аналогичные функции группировались вместе в виде отдельных слоев абстракции.
  2. Для выбора различных реализаций одного и того же варианта поведения системы использовались загружаемые плагины и параметры времени выполнения.
  3. Не допускалось, чтобы абстракция влияла на способы исполнения.

Архитектура слоев абстракции

В Open MPI есть три основных слоя абстракции, которые показаны на рис.15.1:

Рис.15.1: Представление архитектуры абстрактных слоев проекта Open MPI в виде трех основных слоев: OPAL, ORTE и OMPI

Хотя каждая абстракция представляет собой слой, расположенный поверх слоя, лежащего ниже, по причинам, связанным с производительностью, слои ORTE и OMPI могут, когда необходимо, обходить нижележащие слои абстракции и непосредственно взаимодействовать с операционной системой и/или аппаратным обеспечением (так, как показано на рис.15.1). Например, для достижения максимальной производительности сетей в слое OMPI используются методы обхода ОС при взаимодействии с определенными типами аппаратных интерфейсов NIC.

Каждый слой собран в виде отдельной библиотеки. Библиотека ORTE зависит от библиотеки OPAL; библиотеки OMPI зависит от библиотеки ORTE. Разделение слоев на свои собственные библиотеки стало прекрасным инструментом для предотвращения нарушений абстракции. В частности, приложения не смогут быть скомпонованы, если некоторый слой пытается неправильно использовать символ, находящийся на более высоком уровне. На протяжении многих лет такой механизм абстракции защищал многих разработчиков от случайного нарушения границ между этими тремя слоями.

Архитектура плагинов

Хотя первоначально члены сообщества Open MPI стремились к одной и той же основной цели (создать переносимую высокопроизводительную реализацию стандарта MPI), наши организационные возможности, мнения и декларации, да и все тому подобное, были абсолютно различными. Поэтому мы потратили достаточно много времени на разработку архитектуры, которая позволила бы нам оставаться разными даже в случае совместного использования одного и того же базового кода.

Естественным выбором стали компоненты, загружаемые во время исполнения (т.е. динамически разделяемые объекты или «DSO», или «плагины»). Компоненты соответствовали общему интерфейсу API, но они имели незначительные ограничения на реализацию этого API. А именно: одно и то же поведение интерфейса можно было реализовывать несколькими способами. Пользователь мог на этапе исполнения выбрать, какой плагин (плагины) он будет использовать. Это даже позволило третьим лицам самостоятельно разрабатывать и распространять свои собственные плагины Open MPI, которые не входят в состав базового пакета Open MPI. Возможность произвольного расширения является вполне либеральной политикой, причем как непосредственно среди разработчиков Open MPI, так и в гораздо большем по размеру сообществе Open MPI.

Такая гибкость времени выполнения является ключевым компонентом философии проекта Open MPI и она глубоко интегрирована во всей его архитектуре. Показательный пример: серия Open MPI v1.5 включает в себя 155 плагинов. Просто перечислим лишь несколько примеров: есть плагины для различных реализаций memcpy(), плагины для дистанционного запуска процессов на других серверах, и плагины для взаимодействия в различных типах базовых сетей.

Одно из основных преимуществ использования плагинов состоит в том, что несколько групп разработчиков могут свободно экспериментировать с альтернативными реализациями, не затрагивая основной проект Open MPI. Это была очень важно особенно в первое время работы над проектом Open MPI. Иногда разработчики не знают, как правильно что-то реализовать, а иногда просто не соглашаются друг с другом. В обоих случаях, каждая из сторон будет реализовывать свое собственное решения в виде компонента, позволив остальной части сообщества разработчиков легко сравнивать и сопоставлять результаты. Конечно, сравнение кода может быть выполнено без использования компонентов, но концепция компонентов позволяет гарантировать, что все реализации будут находиться в условиях одного и того же внешнего API, и, следовательно, будет обеспечена одна и та же необходимая семантика.

Прямым результатом такой гибкость является то, что она обеспечивает, что компонентная концепция используется в полной мере во всех трех слоях проекта Open MPI; в каждом слое есть много различных типов компонентов. Каждый тип компонента представлен в виде фреймворка. Компонент принадлежит ровно одному фреймворку, а фреймворк поддерживает ровно один вид компонента. На рис.15.2 приведена общая компоновка архитектуры проекта Open MPI; на ней показаны несколько фреймворков Open MPI и некоторые из имеющихся компонентов. Остальные фреймворки и компоненты Open MPI подключены к проекту аналогичным образом. Набор слоев проекта Open MPI, его фреймворки и компоненты называются модульной архитектурой компонентов - Modular Component Architecture (MCA).

Рис.15.2: Архитектурное представление фреймворков в Open MPI — показаны всего лишь несколько фреймворков и компонентов из Open MPI (т.е., плагины). Каждый фреймворк содержит базовый код base и один или несколько компонентов. Эта структура реплицируется в каждом из слоев, показанных на рис.15.1. На данном рисунке показаны примеры фреймворков всех трех слоев: btl и coll относятся к слою OMPI, plm относится к слою ORTE, а timer относится к слою OPAL.

Наконец, еще одним важным преимуществом использования фреймворков и компонентов является присущее им свойство сочетаемости. Наличие в версии MPI v1.5 более чем 40 фреймворков предоставляет пользователям возможность по-разному соединять различные плагины различных типов и позволяет им создавать программный стек, который наиболее эффективен в их конкретной системе.

Фреймворки плагинов

Каждый фреймворк является полностью самодостаточным и находится в своем собственном каталоге в дереве исходного кода Open MPI. Имя подкаталога будет таким же, как и имя фреймворка; например, фреймворк memory находится в каталоге memory. В каталогах фреймворков имеются, по меньшей мере, следующие три составляющих:

  1. Определение интерфейса компонента: Заголовочный файл с именем <framework>.h будет расположен в каталоге фреймворка верхнего уровня (например, в фреймворке Memory будет файл memory/memory.h). В этом всем известном заголовочном файле определяются интерфейсы, которые должны поддерживаться в каждом компоненте. В этом заголовке имеются указатели функций typedef для функций интерфейсов, структуры, предназначенные для работы с указателями с этими функциями, а также все другие необходимые типы, поля атрибутов, макросы, объявления и т.д.
  2. Базовый код: В подкаталоге base находится связующий код, в котором реализован основной набор функций фреймворка. Например, базовым каталогом фреймворка memory будет каталог memory/base. К числу базовых обычно относятся функции, обеспечивающие функционирование фреймворка, например, осуществляющие поиск и открытие компонентов во время выполнения фреймворка, а также утилиты общего назначения, которые могут использоваться несколькими компонентами и т.д.
  3. Компоненты: Все другие подкаталоги в каталоге фреймворка считаются компонентами. Точно также, как и у фреймворка, имена компонентов являются именами подкаталогами (например, в подкаталоге memory/posix находится компонент POSIX фреймворка Memory).

Подобно тому, как в каждом фреймворке определяются интерфейсы, которые должны поддерживаться в его компонентах, в фреймворке также определяются другие особенности функционирования, например, как фреймворки будут загружаться, как будут выбираться используемые компоненты и как будет завершаться работа компонентов. Ниже приведены два примера различий фреймворков: фреймворки типа «несколько из нескольких» и типа «один из нескольких», а также статические и динамические фреймворки.

Фреймворки типа «несколько из нескольких»

Некоторые фреймворки обладают функциями, которые в одном и том же процессе можно реализовывать несколькими различными способами. Например, фреймворк сети типа «точка-точка» в проекте Open MPI будет загружать несколько плагинов драйверов для того, чтобы в одном процессе можно было отправлять и получать сообщения из сетей нескольких типов.

Такие фреймворки, как правило, открывают все компоненты, которые они могут найти, а затем спрашивают у каждого компонента, должны ли они работать. Компоненты, изучая систему, в которой они работают, определяют, должны ли они работать, Например, сетевой компонент типа «точка-точка» определит, если ли и активны ли в системе типы сетей, которые он поддерживает. Если их нет, то компонент ответит, что он не должен запускаться, в результате чего фреймворк закроет и выгрузит этот компонент. Если этот тип сети доступен, то компонент ответит, что он должен быть запущен, в результате чего фреймворк будет держать этот компонент открытым для дальнейшего использования.

Фреймворки типа «один из нескольких»

Другие фреймворки предоставляют функции, для которых не имеет смысла во время выполнения иметь более одной доступной реализации. Например, создание согласованной контрольной точку параллельно выполняемого задания, что значит, что задание может быть «заморожено» и его можно будет возобновились позже, т. е. оно должна использоваться одна и та же система фоновых контрольных точек. Плагин, который взаимодействует с нужной системой фоновых контрольных точек является единственным плагином контрольных точек, который должен загружаться в каждом процессе, а все остальные плагины - не нужны.

Динамические фреймворки

Большинство фреймворков позволяют с помощью разделяемых объектов DSO загружать свои компоненты во время выполнения. Это наиболее гибкий метод поиска и загрузки компонентов; он позволяет явно не указывать конкретные загружаемые компоненты, загружать компоненты сторонних производителей, не входящие в основной дистрибутив проекта Open MPI, и т.д.

Статические фреймворки

Некоторые компоненты типа «один из нескольких» имеют дополнительные ограничения, которые заставляют во время компиляции (а не во время выполнения) выбирать один и только один компонент из нескольких. Статическая компоновка компонентов типа «один из нескольких» позволяет напрямую вызывать функции-члены (и не использовать указатель на функцию), что может быть важно для обеспечения высокой производительности. Одним из примеров является фреймворк memcpy, который предоставляет реализации функции memcpy().

Кроме того, некоторые фреймворки предоставляют функции, которые, возможно, должны быть использованы еще до полной инициализации Open MPI. Например, использование некоторых сетевых стеков требуют сложных моделей регистрации памяти, что, в свою очередь, требует замены процедур управления памятью библиотеки языка C, используемой по умолчанию. Поскольку управление памятью влияет на весь процесс, замена стандартной схемы может быть выполнена только перед запуском основного модуля main. Поэтому такие компоненты должны быть статически скомпонованы с процессами в Open MPI, т. к. обращение к ним может происходить перед запуском модуля main, т. е. задолго до того, как будет инициализирован MPI.

Компоненты плагинов

Плагины Open MPI состоят из двух частей: структуры компонента и структуры модуля. Структура компонента и функций, к которым он обращается, как правило, вместе именуются как «компонент». Аналогичным образом собирательное понятие «модуль» относится к структуре модуля и к его функциям. Деление немного напоминает деление на классы и объекты в языке C++. В каждом процессе есть только один компонент; в нем описывается общий плагин с некоторыми полями, которые являются общими для всех компонентов (независимо от фреймворка). Если компонент выбирается для запуска, то он используется для создания одного или нескольких модулей, которые обычно выполняют основную часть функций, необходимых фреймворку.

На протяжении следующих нескольких разделов мы создадим структуры, необходимые для компонента TCP в фреймворке BTL (слой побайтовой передачи данных). Фреймворк BTL используется при передаче сообщений типа «точка-точка»; компонент TCP, что очевидно, использует TCP в качестве основного транспорта для передачи сообщений.

Структура компонента

Независимо от фреймворка в каждом компоненте есть всем известная статически выделяемая и инициализируемая структура компонента. Структура должна называться согласно шаблону как mca_<framework>_<component>_component. Например, структура драйвера сети TCP во фреймворке BTL называется mca_btl_tcp_component.

Наличие символов компонентов, созданных по шаблону, гарантирует, что между именами компонентов не будет никаких конфликтов, и позволяет ядру MCA искать структуру произвольного компонента при помощи dlsym(2) (или соответствующего эквивалента в каждой поддерживаемой операционной системе).

Структура базового компонента содержит некоторую информацию об используемых ресурсах, например, официальное название компонента, версия, принадлежность версии фреймворка и т.д. Эти данные используются для отладки, учета и во время выполнения для проверки соблюдения совместимости.

struct mca_base_component_2_0_0_t {
    /* Номер версии структуры компонента */
    int mca_major_version, mca_minor_version, mca_release_version;

    /* Строка с именем фреймворка, к которому принадлежит компонент,
       и версия API фреймворка, к которому принадлежит данный компонент */
    char mca_type_name[MCA_BASE_MAX_TYPE_NAME_LEN + 1];
    int mca_type_major_version, mca_type_minor_version,  
        mca_type_release_version;

    /* Имя и номер версии компонента */
    char mca_component_name[MCA_BASE_MAX_COMPONENT_NAME_LEN + 1];
    int mca_component_major_version, mca_component_minor_version,
        mca_component_release_version;

    /* Указатели на функции */  
    mca_base_open_component_1_0_0_fn_t mca_open_component;
    mca_base_close_component_1_0_0_fn_t mca_close_component;
    mca_base_query_component_2_0_0_fn_t mca_query_component;
    mca_base_register_component_params_2_0_0_fn_t 
        mca_register_component_params;
};

Структура базового компонента является основой компонента TCP BTL; она содержит указатели на следующие функции:

Компонентная структура также может быть расширена в каждом конкретном фреймворке и/или в каждом конкретном базисном коде. Во фреймворке обычно создают новую структуру компонента с базовой структурой компонента в качестве первого элемента. Такая вложенность позволяет фреймворкам добавлять свои собственные атрибуты и указателей на функции. Например, для фреймворка, для которого требуется более специализированная функция запроса (в сравнении с функцией query, которая реализована в базовом компоненте), можно добавить указатель на функцию внутри структуры, специализированной под конкретный фреймворк.

Эта методика используется во фреймворке btl в MPI, в котором реализуются функции MPI для передачи сообщений типа «точка-точка».

struct mca_btl_base_component_2_0_0_t {
    /* Структура базового компонента */
    mca_base_component_t btl_version;
    /* Блок данных базового компонента */
    mca_base_component_data_t btl_data;

    /* Функции query, специальные для фреймворка btl */
    mca_btl_base_component_init_fn_t btl_init;
    mca_btl_base_component_progress_fn_t btl_progress;
};

Например, функции query фреймворка TCP BTL и функция btl_init компонента TCP BTL выполняют следующее:

Аналогичным образом плагины могут расширять структуру компонента конкретного фреймворка, добавляя в нее свои собственные элементы. Это делает компонент tcp во фреймворке btl; он кэширует многие члены-данные в своей собственной структуре компонента.

struct mca_btl_tcp_component_t {
    /* Структура компонента, специальная для фреймворка btl */ 
    mca_btl_base_component_2_0_0_t super;

    /* Некоторые данные-члены, специальные для компонента TCP BTL */
    /* Количество интерфейсов TCP на данном сервере */
    uint32_t tcp_addr_count;
    
    /* Дескриптор сокета, слушающего IPv4 */
    int tcp_listen_sd;

    /* ... и многое другое, что здесь не показано */
};

Такая методика вложенных структур является эффектной и простой имитацией одиночного наследования языка C++: указатель на экземпляр структуры struct mca_btl_tcp_component_t может быть приведен к любому из трех типов, т. е. он может использоваться на уровне абстракции, на котором непонятны «производные» типы.

Надо сказать, что такое приведение типов, как правило, не одобряется в Open MPI, поскольку оно может привести к невероятно тонким, трудно обнаруживаемым ошибкам. Исключение может быть сделано для этого варианта эмуляции C++, поскольку в этом случае задается строго определенное поведение, которое помогает соблюдать границы абстракций.

Структура модуля

Структуры модулей определяются индивидуально в каждом фреймворке; между ними мало общего. В зависимости от того, какой используется фреймворк, компонент создает один или несколько экземпляров модулей и укажет, что они должны использоваться.

Например, во фреймворке BTL, один модуль обычно соответствует одному сетевому устройству. Если процесс MPI работает на Linux сервере с тремя устройствами Ethernet, то компонент TCP BTL создаст три модуля TCP BTL; один модуль соответствует каждому устройству Linux Ethernet. Затем каждый из модулей будет полностью ответственен за отправку и получение всех данных через конкретное сетевое устройство.

Объединяем все вместе

На рис.15.3 показана вложенность структур в компоненте TCP BTL и то, как он генерирует по одному модулю для каждой из трех устройств Ethernet.

Рис.15.3: С левой стороны показана вложенность структур в компоненте TCP BTL. Справа показано, как компонент генерирует по одному модулю для каждого интерфейса Ethernet, идущего вверх.

Композиция модулей BTL, таким образом, позволяет движку верхнего уровня MPI обрабатывать все сетевые устройства одинаковым образом и выполнять привязку к каналу пользовательского уровня.

Например, рассмотрим отправку большого сообщения с помощью конфигурации из трех устройств, описанных выше. Предположим, что для того, чтобы достичь предполагаемого получателя, можно использовать любое из трех устройств Ethernet (достижимость определяется сетями TCP, масками и некоторыми строго задаваемыми эвристиками). В данном случае отправитель разделит большое сообщение на множество фрагментов. Каждый фрагмент будет назначен в цикле одному из модулей TCP BTL (поэтому каждому модулю будет назначено примерно одна треть фрагментов). Затем каждый модуль отправляет назначенные ему фрагменты через его собственное соответствующее устройство Ethernet.

Эта схема может показаться сложной, но она удивительно эффективна. За счет того, что пересылка большого сообщения происходит с помощью конвейера через несколько модулей TCP BTL, типичная среда высокопроизводительных вычислений (например, когда каждое устройство Ethernet находится на отдельной шине PCI) может через несколько устройств Ethernet поддерживать почти максимальную пропускную скорость.

Параметры времени выполнения

Разработчики при написании кода часто принимают решения, например, следующие:

Пользователи склонны полагать, что разработчики ответят на подобные вопросы так, что это, в общем случае, подойдет для большинства типов систем. Тем не менее, в сообществе, связанном с высокопроизводительными вычислениями, много ученых и инженеров - опытных пользователей, которые хотят для каждого возможного варианта вычислительного цикла активно настраивать свои аппаратные и программные стеки. Хотя эти пользователи обычно не хотят возиться с фактическим кодом собственной реализации MPI, им интересно в различных обстоятельствах возиться с выбором различных внутренних алгоритмов, выбором различных моделей потребления ресурсов или принудительно задавать конкретные сетевые протоколы.

Поэтому когда разрабатывался проект Open MPI, была добавлена система параметров MCA; система представляет собой гибкий механизм, позволяющий пользователям во время исполнения изменять значения внутренних параметров Open MPI. В частности, разработчики могут везде в базовом коде Open MPI регистрировать строковые и целочисленные параметры MCA, указывающее соответствующее значение, используемое по умолчанию, и строку описания, определяющую, что это за параметр и как он используется. Общее правило состоит в том, что разработчики вместо того, чтобы жестко кодировать константы, используют параметры MCA, которые устанавливаются во время выполнения, что позволяет опытным пользователям настраивать то, как система будет себя вести на этапе выполнения.

В базовом коде трех абстрактных слоев есть ряд параметров MCA, но основная часть параметров MCA системы Open MPI размещены в отдельных компонентах. Например, в плагине TCL BTL есть параметр, определяющий должен ли использоваться только интерфейсы TCPv4, только интерфейсы TCPv6 или оба типа интерфейсов. Кроме того, с помощью еще одного параметра TCP BTL можно точно указывать какие используются устройства Ethernet.

Пользователи могут узнать, какие параметры доступны, с помощью специального, предназначенного для пользователей инструментального средства (ompi_info), работающего из командной строки. Значения параметров можно устанавливать несколькими способами: в командной строке, через переменные среды окружения, через реестр Windows, или с помощью системных или пользовательских файлов в стиле INI.

Система параметров MCA дополнила идею гибкого выбора плагинов во время выполнения и оказалось весьма ценной для пользователей. Хотя разработчики Open MPI старались выбирать разумные значения, используемые по умолчанию в самых разнообразных ситуациях, каждая высокопроизводительная среда имеет свои отличия. Неизбежно существуют среды, для которых не подходят значения параметров, задаваемые в Open MPI по умолчанию, и которые, возможно, даже вредят поддержке высокой производительности. Система параметров MCA позволяет пользователям быть активными и настраивать поведение Open MPI в соответствие с их средой. Это не только упрощает ситуацию с запросами об изменениях в Open MPI и/или с сообщениями об ошибках, но также позволяет пользователям экспериментировать с пространством параметров и находить лучшую конфигурацию для их конкретной системы.

15.3. Усвоенные уроки

Неизбежно, что при наличии такой разношерстной группы основных разработчиков Open MPI, мы должны были каждый раз что-то изучать, и что, как группа, мы должны были многому научиться. Ниже перечислены лишь некоторые из этих уроков.

Производительность

Производительность при передачи сообщений и использование ресурсов являются королем и королевой высокопроизводительных вычислений. Open MPI был специально разработан таким образом, чтобы мог работать на самом переднем крае высокой производительности: невероятно низкие задержки при отправке коротких сообщений, чрезвычайно высокая скорость добавления коротких сообщений в поддерживаемые сети, быстрое достижение максимальной пропускной способности для больших сообщений и т.д. Абстракция является хорошим делом (по многим причинам), но она должна разрабатываться с осторожностью с тем, чтобы она не ухудшала производительность. Или, иначе говоря: тщательно выбирайте абстракции, которые сами понемногу ухудшают производительность стеков вызовов (в сравнении со стеками вызовов с использованием API).

Т.е. также должны нужно признать, что в некоторых случаях следует выбрасывать абстракцию, а не архитектурное решение. Показательный пример: в Open MPI есть фрагменты ассемблерного кода, закодированные вручную, для некоторых из наиболее критичных к производительности операций, например, блокировка совместно используемой памяти и атомарные операции.

Стоит отметить, что на рис.15.1 и 15.2 показаны два различных варианта архитектуры Open MPI. В них не представлены стеки вызовов времени выполнения или обращение к слою вызовов для разделов кода, где нужна высокая производительность.

Усвоенный урок:

Допускается (хотя и нежелательно) и, к сожалению, иногда необходимо иметь объемистый и сложный код для достижения высокой производительности (например, вышеупомянутый ассемблерный код). Тем не менее, всегда предпочтительнее потратить время, пытаясь выяснить, как создать хорошие абстракции с тем, чтобы по мере возможности дискретизировать и скрыть сложность. Несколько недель проектирования могут сэкономить сотни или тысячи часов у разработчиков, которые им потребуются на поддержку запутанного непонятного кода, похожего на спагетти.

Стоя на плечах гигантов

Мы в Open MPI активно пытались избежать заново изобретать код, который кто-то уже написал (если такой код был совместим с лицензией BSD, используемой в Open MPI). В частности, у нас нет никаких угрызений совести относительно непосредственного повторного использования или взаимодействия с чужим кодом.

Когда делается попытка решить очень сложные технические задачи, то не место придерживаться принципа «изобретено не здесь»; единственное, что имеет смысл, всякий раз, когда это возможно, повторно использовать внешний код. Такое повторное использование кода позволяет разработчикам сосредоточиться на проблемах, которые уникальны для проекта Open MPI; нет смысла повторно решать проблему, которая кем-то уже решена.

Хорошим примером такого повторного использования кода является пакет GNU Libtool Libltdl. Libltdl это небольшая библиотека, которая предоставляет переносимый интерфейс API для открытых объектов DSO и поиска в них символов. Libltdl поддерживается в самых различных операционных системах и средах, в том числе и в Microsoft Windows.

В Open MPI можно бы было реализовать эти функциональные возможности самостоятельно, но — зачем? Libltdl является прекрасным образцом программного обеспечения, которое активно поддерживается, совместимо с лицензией Open MPI и предоставляет именно те функциональные возможности, которые были необходимы. Учитывая все это, разработчики Open MPI не получат каких-либо реальных выгод, если заново напишут эти функции.

Усвоенный урок:

Если где-нибудь есть подходящий решение, то не стесняйтесь и воспользуйтесь им и не тратьте время, пытаясь его повторно повторить.

Оптимизация обычно выполняемых операций

Еще один направляющий архитектурный принцип состоит в том, чтобы оптимизировать наиболее часто выполняемые операции. Например, ударение делается на разделение многих операций на две части: на настройку и многократно выполняемое действие. Предполагается, что настройка может оказаться затратной (что означает: медленной). Так что сделаем ее один раз, и покончим с ней. Оптимизируем гораздо более распространенный случай: повторно выполняемую операцию.

Например, функция malloc() может быть медленной, особенно если страницы памяти должны выделяться операционной системой. Поэтому вместо того, чтобы выделить то количество байтов, которое необходимо для одного входящего сетевого сообщения, выделяется место, достаточное сразу для группы входящих сообщений, которое затем делится на буферы отдельных сообщений, и создается список свободных блоков памяти, который поддерживает их использование. Таким образом, первый запрос буфера для сообщения может быть медленным, но последующие запросы будут выполняться гораздо быстрее, поскольку они будут лишь удалением буферов из очереди свободных блоков.

Усвоенный урок:

Разбиваем обычные операций (по крайней мере) на две части: настройка и повторяющееся действие. Мало того, что код будет работать лучше, его, может быть, будет проще поддерживать в течение долгого времени, поскольку различные действия разделены.

Прочие уроки

Было усвоено слишком много других уроков с тем, чтобы их можно было бы здесь подробно описать; приведем еще несколько уроков, которые можно суммировать следующим образом:

Заключение

Если бы нам пришлось перечислить три наиболее важных факта, с которыми мы познакомились в проекте Open MPI, я думаю, что это бы выглядело следующим образом:

На главную -> MyLDP -> Тематический каталог ->

OSCAR

Глава 16 из книги "Архитектура приложений с открытым исходным кодом", том 2.

Оригинал: OSCAR
Автор: Jennifer Ruttan
Дата публикации: 2 Мая 2012 г.
Перевод: А.Панин
Дата перевода: 10 Августа 2013 г.

С момента внедрения системы EMR (системы электронных медицинских записей) были предназначены для осуществления связи между физическими и цифровыми мирами обслуживания пациентов. Правительства стран всего мира пытались предоставить решение, которое позволяло бы лучшим образом обслуживать пациентов при небольшой цене, сокращая объем бумажной документации, которая обычно создается при работе медицинских учреждений. Многие правительства успешно справились с задачей создания такой системы - некоторые, такие, как правительство провинции Онтарио Канады не справились с ней (достаточно вспомнить так называемый "скандал с электронной системой здравоохранения eHealth" в Онтарио, который, судя по отчету Генеральной комиссии по аудиту, обошелся для налогоплательщиков в сумму 1 миллион канадских долларов).

Система EMR позволяет перевести в электронный вид список пациентов и, при корректном использовании, должна упрощать процесс обслуживания пациентов медицинскими работниками. Качественная система должна предоставлять медицинскому работнику возможность точной оценки текущего и последующего состояний пациента, его истории болезни, результатов лабораторных исследований, истории прошлых посещений, и.т.д.

OSCAR (Open Source Clinical Application Resource - программное обеспечение для медицинских учреждений с открытым исходным кодом) является проектом с практически десятилетней историей, созданным в университете McMaster, Гамильтон, Канада и направленным на формирование сообщества вокруг приложения с открытым исходным кодом, работающего с целью передачи в распоряжение медицинских работников описанной системы по низкой цене или вообще бесплатно.

В составе OSCAR имеется ряд подсистем, которые реализуют функции на основе каждого из компонентов системы. Например, компонент oscarEncounter предоставляет интерфейс для прямого взаимодействия с картой пациента; компонент Rx3 является модулем директив, автоматически проверяющим наличие аллергических реакций и непереносимости медицинских препаратов и позволяющим медицинскому работнику отправить предписание по факсу в аптеку непосредственно с помощью пользовательского интерфейса; компонент Integrator позволяет осуществлять обмен данными между несколькими совместимыми друг с другом версиями систем EMR. Все эти отдельные компоненты объединены для формирования стандартной системы для взаимодействия с пользователями OSCAR.

Система OSCAR может не подходить для каждого медицинского работника; например, не все функции системы могут оказаться полезными для специалиста, причем система не может быть просто настроена. Однако, она предоставляет завершенный набор возможностей для повседневного обслуживания пациентов среднестатистическим медицинским работником.

В дополнение к этому система OSCAR прошла сертификацию CMS 3.0 (и была принята к сертификации CMS 4.0), что позволяет медицинским работникам получать спонсорскую помощь в случае установки системы в своей клинике (обратитесь к вебсайту "EMR Advisor" в том случае, если вас интересуют подробности). Сертификат CMS может быть получен после успешной проверки выполнения ряда требований правительства Онтарио и оплаты взноса.

В данной главе мы в общих словах обсудим архитектуру системы OSCAR, описывая ее иерархию, основные компоненты и, что наиболее важно, воздействие на процесс развития проекта решений, принятых в прошлом. В качестве заключения мы обсудим то, как система OSCAR могла быть спроектирована на сегодняшний день в том случае, если бы у нас была возможность заняться этим.

16.1. Системная иерархия

Являясь веб-приложением Tomcat, система OSCAR по большей части следует шаблону проектирования "модель-представление-контроллер" (MVC). Это значит, что код модели (объекты доступа к данным - Data Access Objects, или DAO) отделен от кода контроллера (сервлетов), а их код, в свою очередь, отделен от представлений (генерируемых с использованием технологии Java Server Pages или JSP). Наиболее важным различием между контроллером и представлениями является то, что сервлеты являются классами, а с помощью технологии JSP формируются HTML-страницы, разметка которых производится с помощью кода на языке Java. Данные размещаются в памяти во время выполнения сервлета и при использовании технологии JSP производится чтение этих же данных, обычно путем чтения и записи атрибутов объекта обработки запроса. Практически любая станица, созданная с использованием технологии JSP в рамках системы OSCAR, спроектирован таким же образом.

16.2. Принятые в прошлом решения

Я упоминала о том, что OSCAR является достаточно взрослым проектом. Это обстоятельство оказало воздействие на эффективность применения в рамках проекта шаблона проектирования MVC. Если говорить кратко, существуют участки кода, в которых этот шаблон проектирования вообще не используется ввиду того, что они были разработаны до момента начала активного применения шаблона проектирования MVC. Некоторые из наиболее часто используемых возможностей реализованы именно таким образом; например, выполнение множества действий с демографическими данными (записями пациентов) осуществляется в рамках файла исходного кода demographiccontrol.jsp - эти действия включают создание записей пациентов и обновление их данных.

Возраст системы OSCAR является препятствием для решения множества проблем, затрагивающих дерево исходного кода на сегодняшний день. На самом деле были приложены значительные усилия для улучшения ситуации, среди которых принудительное использование правил проектирования в ходе процесса обзора исходного кода. Этот подход, выбранный сообществом на данный момент, служит для повышения качества процесса взаимодействия в будущем и предотвращения попадания некачественного исходного кода в кодовую базу проекта, что было проблемой в прошлом.

Этот подход ни в коем случае не является ограничением того, как мы могли бы проектировать части системы сегодня; однако, этот подход усложняет процесс принятия решений при исправлении ошибок в устаревших частях системы OSCAR. Если вам или кому-то другому предстоит исправить ошибку в функции создания записи пациента, будете ли вы исправлять ошибку, используя тот же стиль, что был применен при создании существующего кода? Или все-таки вы заново полностью разработаете модуль, точно следуя шаблону проектирования MVC?

Являясь разработчиками, мы должны трепетно взвешивать наши возможности в подобных ситуациях. Не существует гарантии того, что если вы повторно спроектируете часть системы, не будет допущено новых ошибок и в том случае, когда производится работа с реальными данными пациентов, решение должно приниматься чрезвычайно аккуратно.

16.3. Управление версиями

Большую часть периода существования проекта OSCAR для управления деревом исходного кода использовалась система контроля версий CVS. Вносимые изменения обычно не проверялись на корректность, поэтому имелась возможность добавить в репозиторий код, который мог привести к невозможности сборки. Разработчикам было сложно отслеживать изменения, особенно в случае присоединения к команде новых разработчиков на поздних этапах жизненного цикла проекта. Новый разработчик мог увидеть что-либо, что он желал бы изменить, внести изменения и отправить их в ветку исходного кода за несколько недель до того, как кто-либо заметит значительные модификации (данная ситуация особенно актуальна в период длительных праздников, таких, как Рождественские каникулы, в течение которых очень малое количество людей исследует дерево исходного кода).

Но положение вещей изменилось: дерево исходного кода проекта OSCAR на сегодняшний день находится под управлением системы контроля версий git. Любые модификации кода из основной ветки должны преодолеть проверку стиля кода и модульное тестирование, быть успешно скомпилированы и проверены разработчиками. (Большая часть этой работы выполняется с помощью комбинации сервера системы непрерывной интеграции Hudson и инструмента проверки стиля исходного кода Gerrit.) Управление проектом стало более надежным. Многие проблемы, вызванные некорректной работой с деревом исходного кода проекта, были решены.

16.4. Модели данных/DAO

При изучении дерева исходного кода проекта OSCAR вы можете заметить, что существует множество различных способов осуществления доступа к базе данных: вы можете использовать прямое соединение с базой данных с помощью класса с именем DBHandler, устаревшее соединение с использованием модели Hibernate или модель JPA общего назначения. По мере появления новых и более простых моделей взаимодействия с базой данных, они интегрируются в состав проекта OSCAR. В результате на данный момент процесс взаимодействия системы OSCAR с данными из базы данных MySQL является не достаточно очевидным и различия между тремя описанными методами доступа к данным могут быть описаны лучшим образом с помощью примеров.

EForms (DBHandler)

Система EForm позволяет пользователям создавать свои формы для привязки их к записям пациентов - эта возможность обычно используется для замены бумажных бланков на их цифровые версии. При каждом создании формы определенного типа загружается шаблон формы из файла; после этого данные формы сохраняются в базе данных для каждого ее экземпляра. Каждый экземпляр формы привязывается к записи пациента.

Система EForms позволяет вам запрашивать определенные типы данных из списка пациентов или другой области данных системы с использованием SQL-запрсов в свободной форме (которые заданы в файле с именем apconfig.xml). Это может быть очень полезным, так как форма может быть загружена, после чего немедленно заполнена демографическими данными или другой соответствующей информацией без вмешательства пользователя; например, вам не придется вписывать имя пациента, его возраст, дату рождения, место рождения, номер телефона или последнюю медицинскую запись при работе с конкретным пациентом.

При начальном проектировании модуля EForm было принято архитектурное решение, заключающееся в использовании необрабатываемых запросов к базе данных для заполнения POJO (простого Java-объекта в старом стиле - plain-old Java object) с именем EForm в контроллере, который впоследствии передается на уровень представления для вывода данных на экран так, как это сделано с JavaBean. Использование объекта POJO в данном случае приближает архитектурное решение к решениям Hibernate или JPA, о которых я расскажу в следующих разделах.

Все функции, относящиеся к сохранению экземпляров класса EForm и шаблонов, осуществляются с помощью необрабатываемых SQL-запросов, выполняемых классом DBHandler. В конечном счете, класс DBHandler является оберткой над простым объектом JDBC и не проводит исследование запроса перед его оправкой серверу SQL. Следует добавить, что использование класса DBHandler является потенциальной угрозой безопасности, так как он позволяет отправлять серверу непроверенные SQL-запросы. Любой класс, использующий класс DBHandler, должен реализовывать свои собственные алгоритмы проверки для того, чтобы быть уверенным в неосуществимости SQL-инъекции.

В зависимости от типа приложения, которое вы разрабатываете, прямой доступ к базе данных иногда может оказаться подходящим решением. В определенных случаях такая возможность позволяет даже повысить скорость разработки. Использование этого метода для доступа к базе данных не соответствует шаблону проектирования "модель-представление-контроллер", хотя в том случае, если хотите изменить структуру вашей базы данных (модель), вам придется изменить SQL-запрос в другом месте (в контроллере). Иногда добавление определенных столбцов или изменение их типов в таблицах базы данных системы OSCAR требует выполнения подобной процедуры вмешательства всего лишь для реализации простейших возможностей.

Вас может не удивлять тот факт, что объект DBHandler описан в одной из старейших частей исходного кода и все еще является нетронутым. Лично я не знаю, где он возник, но я предполагаю, что этот класс является наиболее "примитивным" типом класса для доступа к базе данных в рамках дерева исходного кода системы OSCAR. Новый исходный код не может использовать этот класс, а в том случае, если использующий его исходный код все же попытаются добавить в репозиторий, он будет автоматически отклонен.

Демографические записи (Hibernate)

Демографическая запись содержит основные метаданные, имеющие отношение к пациенту: например, его имя, возраст, адрес, родной язык и пол; будем считать, что эти данные появляются после заполнения пациентом формы приема в ходе его первого визита к врачу. Все эти данные извлекаются и выводятся в форме части мастер-записи системы OSCAR (OSCAR's Master Record) для определенной демографической записи.

Использование Hibernate для осуществления доступа к базе данных значительно безопаснее использования класса DBHandler. С одной стороны вам приходится четко указывать то, какие столбцы соответствуют каким полям вашего объекта модели (в данном случае, класса Demographic). Если вы хотите выполнить сложные объединения запросов, они могут быть осуществлены с использованием заранее подготовленных объявлений. Наконец, вы получите объект исключительно того типа, который вы описывали при осуществлении запроса, что очень удобно.

Процесс работы с парами объектов доступа к данным (DAO) и моделей при использовании Hibernate достаточно прост. В случае объекта Demographic существует файл с именем Demographic.hbm.xml, который описывает связи между полями объекта и столбцами таблицы базы данных. Файл описывает то, к какой таблице следует обратиться, а также какой тип объекта следует вернуть. При запуске системы OSCAR данный файл должен быть прочитан, после чего должна быть проведена проверка достоверности прочитанных данных, предназначенная для того, чтобы быть уверенным в реальной возможности создания описанного типа связей (в случае неудачной проверки процесс запуска севера прерывается). В процессе работы сервера вы можете создать экземпляр класса DemographicDao и выполнять запросы с помощью него.

Преимуществом использования Hibernate по сравнению с DBHandler является то, что все запросы к серверу базы данных осуществляются с использованием заранее подготовленных объявлений. Это обстоятельство ограничивает возможность свободного выполнения SQL-запросов в процессе работы системы, но при этом также предотвращает любые типы атак на основе SQL-инъекций. Hibernate всегда будет формировать сложные запросы для выборки данных, причем запросы не всегда формируются чрезвычайно эффективным способом.

В предыдущем разделе я упомянула о примере модуля EForm, использующего класс DBHandler для заполнения объекта POJO. Это еще один логический шаг, направленный на предотвращение разработки подобного кода. В случае изменения модели придется изменить только файл с расширением .hbm.xml и класс модели (добавить новое поле и новые функции для получения/установки значений в новом столбце), причем эти действия не затронут остальных частей приложения.

Хотя метод работы с Hibernate и современнее метода обращения к базе данных посредством класса DBHandler, он начинает устаревать. Его не всегда удобно использовать, а также он требует файла конфигурации большого размера для каждой таблицы базы данных, доступ к которой вы хотите получить. Добавление новой пары объектов занимает время и в том случае, если вы выполните эту операцию некорректно, система OSCAR даже не начнет работу. По этой причине в любом случае никто не должен разрабатывать новый код, использующий Hibernate напрямую. Для замены описанной технологии на новом этапе разработки была предложена технология JPA.

На главную -> MyLDP -> Тематический каталог ->

OSCAR

Глава 16 из книги "Архитектура приложений с открытым исходным кодом", том 2.

Оригинал: OSCAR
Автор: Jennifer Ruttan
Перевод: А.Панин

Интегратор согласования (JPA)

Новейший метод доступа к базе данных заключается в использовании стандартного API для долговременного хранилища данных Java (Java Persistent API - JPA). В том случае, если бы в рамках проекта OSCAR было принято решение о переходе от использования Hibernate к использованию другого соответствующего стандарту JPA для объектов DAO и моделей API для доступа к базе данных, процесс миграции стал бы проще. К сожалению, так как подобные технологии являются слишком "новыми" для проекта OSCAR, практически не существует частей системы, которые фактически используют предлагаемый метод для получения данных.

В любом случае, позвольте мне дать пояснения относительно данного метода. Вместо файла с расширением .hbm.xml вы можете добавлять аннотации к вашим объектам модели и DAO. Эти аннотации описывают таблицу базы данных, к которой следует обратиться, связи между полями объекта и столбцами базы данных, а также методы объединения запросов. Вся информация хранится в двух файлах и для работы больше ничего не требуется. На заднем плане все еще функционирует Hibernate, выполняя функции фактического извлечения данных из базы.

Все модели интегратора создаются с использованием функций JPA являются как замечательными примерами нового стиля доступа к базе данных, так и демонстрацией метода реализации новой технологии в рамках системы OSCAR. Данная модель все еще не используется во многих местах системы. Интегратор является относительно новым дополнением к исходному коду. Поэтому есть смысл использования этой новой модели доступа к данным вместо прямого использования Hibernate.

Затронем ставшую привычной в этой части раздела тему об аннотациях объектов POJO, которые используются в рамках JPA для реализации отлаженного процесса обработки данных. Например, во время процесса сборки интегратора создается файл SQL, который позволяет вам создать все необходимые таблицы базы данных - это чрезвычайно полезная возможность. Благодаря этой функции становится невозможным создание не соответствующих друг другу таблиц и объектов моделей (что вы можете сделать при использовании любого другого типа метода доступа к базе данных) и вам никогда не придется беспокоиться о именовании столбцов и таблиц. Прямые SQL-запросы не осуществляются, поэтому невозможно провести атаки на основе SQL-инъекций. Одним словом, этот метод "просто работает".

Принцип работы JPA может рассматриваться как аналогичный принципу работы системы ActiveRecord из состава фреймворка Ruby on Rails. Класс модели описывает типы данных, а также базу данных, которая хранит их; то же, что происходит с ними - добавление и извлечение - не должно волновать пользователя.

Недостатки Hibernate и JPA

Технологии Hibernate и JPA предоставляют некоторые преимущества при их стандартном использовании. В том случае, когда они используются для простого получения и сохранения данных, они позволяют значительно сократить время, затрачиваемое на разработку и отладку приложения.

Однако, это не означает, что их реализация в рамках системы OSCAR идеальна. Так как пользователь не описывает SQL-запросы, используемые при взаимодействии с базой данных для заполнения объекта POJO, соответствующего определенной строке, Hibernate предоставляется выбор лучшего способа осуществления данной операции. "Лучший способ" может быть представлен несколькими вариантами: Hibernate может выбрать метод простого извлечения данных строки или выполнить объединение запросов и получить большой объем информации единовременно. Иногда механизм объединения запросов может выходить из-под контроля.

Еще один пример: таблица casemgmt_note содержит все записи пациентов. Каждая запись содержит большое количество относящихся к ней метаданных, но она также содержит список всех особых характеристик пациента, которые приходится учитывать при использовании информации из записи (эти характеристики могут быть представлены такими записями, как "отказ от курения", "диабет", которые разъясняют содержимое записи). Список особых характеристик пациента представлен в объекте записи в виде списка List<CaseManagementIssue>. Для получения этого списка таблица casemgmt_note должна объединяться с таблицей casemgmt_issue_note (которая выступает в роли таблицы соответствия) и, наконец, с таблицей casemgmt_issue.

Если вы захотите написать специфический запрос для Hibernate, что требуется в описанной выше ситуации, вам не придется использовать стандартный язык SQL - вместо него нужно использовать HQL (язык запросов Hibernate - Hibernate Query Language), который впоследствии будет преобразован в SQL (путем вставки внутренних имен столбцов для всех полей выборки) перед вставкой параметров и отправкой запроса серверу базы данных. В этом специфическом случае для написания запроса были использованы стандартные объединения без объединения столбцов и это означает, что тогда, когда запрос в конечном счете был преобразован в представление SQL, он был настолько длинным, что не было понятно, манипуляции с какими данными осуществляются. В дополнение к этому практически во всех случаях этот запрос не создает достаточно большой таблицы, процесс создания которой можно было бы заметить. Для большинства пользователей этот запрос выполняется достаточно быстро и поэтому он незаметен. Однако, эффективность этого запроса невероятно низка.

Давайте на секунду сделаем шаг назад. В тот момент, когда вы выполняете объединение двух таблиц, серверу приходится создавать временную таблицу в памяти. В большинстве случаев применения стандартных типов объединений количество строк результирующей таблицы равняется количеству строк первой таблицы, умноженному на количество строк второй таблицы. Таким образом, в том случае, если ваша таблица содержит 500,000 строк и вы объединяете ее с таблицей, содержащей 10,000,000 строк, вы создаете временную таблицу, состоящую из 5x1012 строк в памяти, после чего из нее осуществляется выборка данных и занятая память освобождается.

В одном из экстремальных случаев, с которым мы столкнулись, в результате объединения трех таблиц была создана временная таблица размером около 7x1012 строк, из которой в конечном счете было извлечено около 1000 строк. Эта операция заняла 5 минут и таблица casemgmt_note была заблокирована в течение всего времени ее выполнения.

В конечном счете проблема была решена путем использования заранее подготовленных объявлений, которые ограничивали область выборки в первой таблице до момента ее объединения с двумя другими таблицами. Новый, гораздо более эффективный запрос снизил количество строк для выборки до приемлемого значения 300,000 и значительно увеличил производительность операции получения данных записей (время, необходимое для осуществления такой же выборки сократилось до 0.1 секунды).

Мораль этой истории достаточно проста: хотя Hibernate и выполняет свою работу достаточно хорошо, но пока операция объединения таблиц не достаточно точно описана и управляема (либо с помощью файла с расширением .hbm.xml, либо с помощью аннотации объединения в рамках класса модели JPA), она может очень быстро выйти из-под контроля. Работа с объектами вместо SQL-запросов требует от вас передачи инициативы по реализации запроса библиотеке для доступа к базе данных и в реальности позволяет вам контролировать исключительно описание операции. Пока вы не начнете тщательно описывать операции, эта библиотека может работать некорректно в экстремальных условиях. Более того, в том случае, если вы разработчик баз данных, обладающий знаниями в области языка запросов SQL, эти знания не окажутся особо ценными при проектировании класса с поддержкой JPA, который лишает вас некоторого контроля над запросами, которым вы могли бы обладать в случае самостоятельной разработки SQL-запросов. В конечном счете можно сделать вывод о том, что для работы необходимы хорошие знания как в области языка SQL, так и в области аннотаций JPA, а также понимание того, как эти аннотации влияют на результирующие запросы.

16.5. Права доступа

Проект CAISI (проект системы клиентского доступа к интегрированным службам и информации - Client Access to Integrated Services and Information) изначально был отдельным продуктом, созданным в результате форка системы OSCAR и предназначенным для управления заведениями для бездомных в Торонто. В конце концов было принято решение о переносе наработок проекта CAISI в форме кода в основную ветку исходного кода. Оригинальный проект CAISI мог больше не развиваться, но при этом из его состава была получена очень важная часть системы OSCAR: модель прав доступа.

Модель прав доступа системы OSCAR предельно мощна и может использоваться для создания такого большого количества ролей и наборов прав доступа, какое только возможно. Провайдеры (providers) принадлежат программам (programs) (в качестве обслуживающего персонала (staff)), в рамках которых они выступают в определенной роли (role). Каждая программа находится в учреждении (facility). Каждая роль имеет описание (например, "доктор", "медицинская сестра", "социальный работник", и.т.д.) и набор соответствующих глобальных прав доступа. Права доступа записываются в формате, облегчающем их понимание: "read nurse notes" ("читать записи медицинских сестер") может описывать права доступа, которые может иметь роль доктора, при этом роль медицинской сестры может не иметь прав доступа "read doctor notes" ("читать записи доктора").

Этот формат может быть простым для понимания, но при более подробном рассмотрении оказывается, что он требует немного сложной работы для проверки таких типов прав доступа. Название роли, в которой выступает рассматриваемый провайдер, проверяется по списку прав доступа путем поиска совпадения с действием, попытка выполнения которого предпринимается. Например, попытка провайдера прочитать записи доктора приведет к проверке права доступа "read doctor notes" для каждой из записей, созданных доктором.

Другой проблемой является преимущественное использование английского языка для описания прав доступа. Любой пользователь системы OSCAR, применяющий отличающийся от английского язык все еще должен описывать права доступа в формате, подобном следующему "read [роль] notes" с использованием таких английских слов, как "read", "write", "notes" и других.

Модель прав доступа системы CAISI является важной частью системы OSCAR, но она не является единственной доступной моделью. Перед тем, как была реализована система CAISI, разрабатывалась другая модель на основе ролей (но не на основе программ), которая и на сегодняшний день используется во многих частях системы.

В этой модели провайдеры ставятся в соответствие одной или нескольким ролям (например, "доктор", "медицинская сестра", "администратор" и другим). Они могут выступать в таком количестве ролей, в каком это необходимо - соответствующие ролям разрешения размещаются друг над другом в стеке. Эти права доступа главным образом используются для запрета доступа к частям системы в отличие от прав доступа системы CAISI, которые запрещают доступ к определенным наборам данных из карты пациента. Например, пользователю необходимо иметь право доступа "_admin" "read" в рамках используемой роли для того, чтобы иметь возможность доступа к панели администрирования. Однако, право доступа "read" лишит пользователей возможности выполнения административных задач. Для выполнения этих задач ему потребуется также право доступа "write".

Обе эти системы предназначены для выполнения практически одной и той же задачи; из-за более поздней интеграции кода системы CAISI в ходе жизненного цикла проекта рассматриваемые системы существуют параллельно. Данные системы не всегда успешно соседствуют друг с другом, поэтому в реальности гораздо проще сфокусироваться на использовании одной из них для повседневной эксплуатации системы OSCAR. В общем случае вы можете оценить возраст кода системы OSCAR, зная о том, какие модели прав доступа предшествовали другим моделям: модель на основе типов провайдеров (Provider Type), модель на основе ролей провайдеров (Provider Roles), модель на основе программ и провайдеров системы CAISI (CAISI Programs/Roles).

Старейшим типом модели прав доступа является модель на основе типов провайдеров ("Provider Type"), которая настолько устарела, что сегодня не используется в большинстве частей системы и фактически использует исключительно роль "доктор" в ходе создания нового провайдера, так как использование любой другой роли (такой, как "регистратор") приводит к проблемам во всей системе. Управление правами доступа при использовании модели на основе ролей провайдера (Provider Roles) вместо описанной выше может осуществляться проще и точнее.

16.6. Интегратор

Компонент с названием "интегратор" системы OSCAR является отдельным независимым от OSCAR веб-приложением, которое используется экземплярами системы для обмена информацией о пациентах, программах и провайдерах по защищенному каналу. Дополнительно он может быть установлен в качестве компонента такого окружения, как LHN (локальная сеть учреждения здравоохранения - Local Health Network) или клиники. В простейшем случае интегратор может быть описан, как система временного хранения данных.

Представим следующий вариант, аргументирующий необходимость использования интегратора: в составе клиники X существует ЛОР-отделение (занимающееся лечением заболеваний ушей, носа и горла), а также эндокринологическое отделение. В том случае, если ЛОР-врач отправляет пациента к эндокринологу на этаж выше, ему может потребоваться передать вместе с пациентом историю болезни и дополнительные записи. В данном случае неудобно использовать бумагу, тем более бумаг может оказаться больше, чем необходимо - возможно, пациенту после однократного визита больше не понадобится посещать эндокринолога. При использовании интегратора доступ к данным пациента может быть осуществлен с использованием системы электронных медицинских записей эндокринолога, причем доступ к содержимому карты пациента может быть закрыт после его визита.

Более экстремальный пример: в том случае, если человек доставляется в клинику в бессознательном состоянии и система электронных медицинских записей не может найти ничего, кроме его карты медицинского страхования, в случае соединения систем его домашней клиники и госпиталя с помощью интегратора, появляется возможность получения записей из истории болезни данного человека, на основании которых можно быстро выяснить то, что ему был прописан антикоагулянт под названием "варфарин". В конечном счете, функция извлечения информации, похожая на описанную выше, является возможностью, которая может быть реализована такими системами электронных медицинских записей, как OSCAR совместно с интегратором.

Технические подробности

Интегратор доступен исключительно в форме исходного кода, что требует от пользователя действий, направленных на его получение и ручную сборку. Как и в случае системы OSCAR он выполняется в окружении, создаваемом путем стандартной установки Tomacat с MySQL.

При доступе к URL, используемому интегратором, последний не выводит какой-либо полезной информации. Этот компонент является практически в полной мере веб-сервисом; система OSCAR взаимодействует с URL интегратора посредством отправки POST- и GET-запросов.

Являясь независимо разрабатываемым проектом (изначально частью проекта CAISI), интегратор довольно строго следует шаблону проектирования MVC. Разработчики оригинальных версий проекта проделали великолепную работу, изначально очень четко установив связи между моделями, представлениями и контроллерами. Наиболее новым реализованным типом уровня доступа к базе данных, о котором я упоминала ранее, является стандартный уровень, использующий технологию JPA и являющийся при этом единственным подобным уровнем проекта. (В качестве интересного примечания следует упомянуть о том, что из-за использования аннотации JPA для всех классов моделей в рамках всего проекта сценарий для работы с SQL создается во время сборки и может использоваться для инициализации структуры базы данных; следовательно, в комплекте поставки интегратора нет отдельного сценария для работы с SQL.)

Взаимодействие с интегратором происходит с помощью отправки запросов веб-сервиса, описанных в файлах WSDL XML, которые доступны на сервере. Клиент может отправить запрос интегратору для выяснения того, какие типы функций доступны и использовать их. Фактически это означает, что интегратор совместим с любым типом системы электронных медицинских записей для которой кто-либо захочет разработать код клиента; формат данных является достаточно общим, поэтому он хорошо совместим с локальными типами данных.

При этом в случае системы OSCAR клиентская библиотека встроена и включена в основное дерево исходного кода для упрощения использования. Обновление этой библиотеки требуется только тогда, когда становятся доступны новые функции на стороне интегратора. Исправление ошибок интегратора не требует обновления этого файла.

16.6. Интегратор

Архитектура

Данные для работы интегратора передаются со стороны всех соединенных с ним систем электронных медицинских записей в установленное время и после их доставки другая система электронных медицинских записей может запросить эти данные. При этом никакие из данных не хранятся интегратором на постоянной основе - его база данных может быть очищена и заполнена заново данными от клиентов.

Набор отправляемых данных настраивается индивидуально на стороне каждого из экземпляров системы OSCAR, соединенного с определенным интегратором и, за исключением случаев, когда база пациентов в полном объеме должна быть передана серверу интегратора, ему передаются только те записи пациентов, которые были просмотрены с момента последней отправки данных. Этот процесс не является точной копией процесса наложения патча, но очень похож на него.

Обмен данными между системами OSCAR и интегратором
Рисунок 16.1: Обмен данными между системами OSCAR и интегратором

Позвольте мне объяснить принцип работы интегратора более подробно с помощью примера: в удаленной клинике хотят посмотреть карту пациента из другой клиники. При желании персонала этой клиники получить доступ к записи пациента, следует первую очередь подключить эти клиники к одному и тому же интегратору. Регистратор может осуществить поиск удаленного пациента с помощью интегратора (по имени и в случае необходимости по дате рождения или полу) и найти запись необходимого пациента, хранимую на сервере. Он инициирует копирование ограниченного набора демографической информации пациента, после чего подтвердит намерения пациента, получив его согласие на извлечение данных путем заполнения формы согласия. После этого сервер интегратора передаст всю информацию, известную интегратору о пациенте - записи, сведения о прописанных препаратах, аллергических реакциях, прививках, дополнительные документы, и.т.д. Эти данные кэшируются локально, поэтому локальной копии системы OSCAR не придется отправлять интегратору запрос каждый раз, когда потребуется обратиться к этим данным, но следует учесть и то, что срок хранения локального кэша ограничивается одним часом.

Демографическая информация и соответствующие данные отправляются интегратору в процессе передачи данных из домашней клиники. Хранящаяся на интеграторе запись может быть не полным представлением записи из домашней клиники, так как система OSCAR позволяет выбрать вариант отправки данных пациента не в полном объеме.
Рисунок 16.2: Демографическая информация и соответствующие данные отправляются интегратору в процессе передачи данных из домашней клиники. Хранящаяся на интеграторе запись может быть не полным представлением записи из домашней клиники, так как система OSCAR позволяет выбрать вариант отправки данных пациента не в полном объеме.

После начальной настройки записи пациента, выполненной путем копирования его демографических данных в локальную систему OSCAR, запись пациента начинает работать точно так же, как и любая другая запись в рамках системы. Все данные с удаленной системы, принятые от интегратора получают соответствующую метку (также вместе с данными сохраняется информация о клинике, из которой они получены), но они всего лишь временно кэшируются в рамках локальной системы OSCAR. Любые сохраняемые локальные данные записываются абсолютно также, как и любые другие данные пациента в записи пациента, после чего отправляются интегратору, но при этом они не хранятся на удаленной машине на постоянной основе.

Удаленная система OSCAR запрашивает данные у интегратора, указывая на определенную запись пациента. Сервер интегратора отправляет исключительно демографическую информацию, которая хранится на постоянной основе удаленной системой OSCAR.
Рисунок 16.3: Удаленная система OSCAR запрашивает данные у интегратора, указывая на определенную запись пациента. Сервер интегратора отправляет исключительно демографическую информацию, которая хранится на постоянной основе удаленной системой OSCAR.

Этот процесс имеет очень важное значение, особенно для получения согласия пациента и понимания воздействия описанных факторов на архитектуру интегратора. Представим, что пациент посетил врача из удаленной клиники и согласился на предоставление доступа к своей записи, но только на некоторое время. После визита пациент может аннулировать соглашение о возможности открытия персоналом этой клиники его записи и в следующий раз при открытии карты пациента из этой клиники данные не будут доступны (за исключением тех данных, которые были сохранены локальной системой). В конечном счете этот механизм позволяет пациенту непосредственно контролировать то, как и когда его запись может быть просмотрена аналогично посещению клиники с бумажной копией карты пациента. Персонал клиники сможет ознакомиться с картой только при общении с вами, но вы заберете ее домой тогда, когда покинете клинику.

Персонал удаленной клиники может просматривать содержимое карты пациента, запрашивая данные; в случае согласия пациента данные передаются. Данные никогда не хранятся на постоянной основе удаленной системой OSCAR.
Рисунок 16.4: Персонал удаленной клиники может просматривать содержимое карты пациента, запрашивая данные; в случае согласия пациента данные передаются. Данные никогда не хранятся на постоянной основе удаленной системой OSCAR.

Другой очень важной для медиков возможностью является возможность принятия решения о том, какие данные они хотели бы передавать другим присоединенным клиникам посредством своего сервера интегратора. Персонал клиники может выбрать передачу всех данных из демографической записи или только частей этой записи, например, записей без документов, информации об аллергических реакциях без предписаний, и.т.д. В конечном счете решение о том, какими типами данных было бы удобно делиться друг с другом принимается группой медиков, настраивающих сервер интегратора.

Как я упоминала ранее, интегратор является исключительно системой временного хранения данных и с помощью него данные никогда не сохраняются на постоянной основе. Это еще одно очень важное решение, которое было принято в ходе разработки; оно позволяет клиникам очень просто отказаться от передачи всех данных с помощью интегратора и, фактически в случае необходимости может быть очищена вся база данных интегратора. В случае очистки базы данных пользователи клиентских систем не заметит этого, так как данные будут аккуратно реконструированы на основе исходных данных, находящихся на различных соединенных с интегратором клиентских системах. Эта возможность влечет за собой требование, согласно которому предоставляющая данные система OSCAR должна доверять получающему данные интегратору, предоставляя возможность очистки базы данных по первому требованию, следовательно, лучшим решением является развертывание интегратора группой медицинских работников из такой зарегистрированной согласно требованиям законодательства организации, как Family Health Organization или Family Health Team; в этом случае сервер интегратора будет эксплуатироваться одной из клиник, в которой работают эти специалисты.

Формат данных

Клиентские библиотеки интегратора созданы с помощью программного компонента wsd2java, который формирует набор классов, представляющих соответствующие типы данных, используемые веб-сервисом в ходе взаимодействия с клиентами. Среди них присутствуют классы для каждого из типов данных наряду с классами, представляющими ключи для каждого из этих типов данных.

Описание способа сборки клиентской библиотеки интегратора выходит за границы данной главы. Единственной важной вещью в данной связи является информация о том, что сразу же после сборки библиотеки она должна быть добавлена в комплект поставки системы OSCAR ко всем остальным JAR-файлам. Этот JAR-файл содержит все необходимое для установления соединения с интегратором и доступа ко всем типам данных, которые сервер интегратора будет передавать системе OSCAR, такими, как CachedDemographic, CachedDemographicNote и CachedProvider наряду с многими другими. В дополнение к типам данных, которые передаются, существуют "WS"-классы, которые используются в первую очередь для получения таких наборов данных, как наиболее часто используемый класс DemographicWs.

Работа с данными интегратора иногда может оказаться немного усложненной. Система OSCAR не имеет каких-либо жестко интегрированных механизмов для работы с типом данных, который обычно используется при получении определенного типа информации о пациенте (например, записей из медицинской карты) в момент, когда клиент интегратора отправляет запрос для получения данных от сервера. После этого данные вручную преобразуются в локальный класс, представляющий эти данные (в случае записей это класс CaseManagementNote). В рамках класса типа данных устанавливается логический флаг, указывающий на то, что это класс содержит данные от удаленной системы и влияющий на то, как данные будут выводиться на экран пользователя. С другой стороны, класс CaisIntegratorUpdateTask обрабатывает выборку локальных данных системой OSCAR, преобразование их в формат данных интегратора и последующую отправку этих данных серверу интегратора.

Эта архитектура может и не является такой эффективной и прозрачной, как могла бы, но она позволяет устаревшим в большей степени частям системы стать "совместимыми" с доставляемыми интегратором данными без значительных модификаций. В дополнение к этому поддержание представления таким простым, как это возможно путем осуществления обращений только к одному типу класса улучшает читаемость JSP-файла и упрощает процесс отладки в случае обнаружения ошибки.

16.7. Выученные уроки

Как вы, возможно, представляете, система OSCAR имеет свой набор недостатков, присущих ее архитектуре. Однако, она предоставляет завершенный набор возможностей, с которым у большинства пользователей не возникает никаких проблем. В этом и заключается главная цель проекта: предоставить качественное решение, функционирующее в большинстве ситуаций.

Я не могу говорить от лица всего сообщества разработчиков системы OSCAR, поэтому в данном разделе будет отражена лишь моя субъективная точка зрения. Мне кажется, что существуют некоторые важные темы, не относящиеся к аспектам архитектуры проекта.

Во-первых, очевидно, что недостаточный контроль над исходным кодом в прошлом привел к тому, что архитектура системы стала достаточно неупорядоченной в некоторых местах, особенно в тех областях, где контроллеры и представления смешивались друг с другом. Путь, по которому проект развивался в прошлом, не позволил предотвратить подобные последствия, но с того времени процесс разработки претерпел значительные изменения и, надеюсь, что проект не столкнется с подобной проблемой снова.

Еще следует упомянуть о том, что из-за достаточного возраста проекта становится сложно обновить (или даже изменить) библиотеки без причинения значительных неудобств остальной кодовой базе. Кстати, это именно ситуация, которая и произошла. Мне обычно сложно выяснить, что важно, а что нет при исследовании директории библиотек. В дополнение к этому следует упомянуть о том, что иногда при значительных обновлениях библиотек они нарушают обратную совместимость (изменение названий пакетов является стандартной причиной). Обычно в комплекте поставки системы OSCAR присутствует несколько библиотек, выполняющих одну и туже задачу - это результат недостаточного контроля над исходным кодом, а также того факта, что не было создано документации со списком библиотек и описанием того, какая библиотека требуется для какого из компонентов.

Дополнительно следует отметить то, что проект OSCAR не достаточно гибок при добавлении новых функций в существующие подсистемы. Например, в том случае, когда вы хотите добавить новое поле в электронную карту пациента, вам придется создать новую JSP-страницу и новый сервлет, модифицировать шаблон электронной карты (в нескольких местах) и модифицировать конфигурационный файл приложения для возможности загрузки вашего сервлета.

Кроме того, ввиду отсутствия документации иногда практически невозможно выяснить то, как работает часть системы - человек, разработавший оригинальный код, может уже не участвовать в проекте и обычно единственным доступным инструментом, позволяющим вам выяснить это, является отладчик. В случае рассмотрения проекта такого возраста ценой этого обстоятельства являются утраченные возможности новых потенциальных участников, которые могли бы начать работу в рамках проекта. Однако, благодаря совместным усилиям участников проекта и схожим факторам, сообщество продолжает работу.

Наконец, система OSCAR является репозиторием медицинской информации и ее безопасность значительно снижается из-за включения в комплект поставки класса DBHandler (описанного в предыдущем разделе). Лично я считаю, что принимающие параметры запросы к базе данных в свободной форме ни в коем случае не должны быть разрешены в системе электронных медицинских записей, так как с помощью них можно достаточно просто производить атаки на основе SQL-инъекций. Хотя запрет на разработку нового, использующего данный класс кода и является правильным решением, приоритетная задача команды разработчиков должна заключаться в удалении всех вариантов использования данного класса.

Все эти слова могут звучать как жесткая критика проекта. В прошлом все описанные проблемы были заметны и, как я говорила, они сдерживали рост сообщества из-за высокого барьера вхождения в проект. Но ситуация меняется, поэтому в будущем эти проблемы не будут настолько большим препятствием на пути развития.

Оглядываясь назад и рассматривая историю развития проекта (особенно в течение выхода нескольких последних версий), мы можем лучшим образом спроектировать приложение. Система все так же должна будет предоставлять базовый набор функций (установленный правительством Онтарио для сертификации приложения как системы электронных медицинских записей), поэтому эти функции должны быть реализованы по умолчанию. Но в том случае, если архитектура системы OSCAR будет изменяться сегодня, она должна стать по-настоящему модульной и позволять рассматривать модули как плагины; если вам не нравился модуль электронной формы, вы получите возможность создать свою собственную реализацию (или даже полностью отличный модуль). У системы должна появится возможность взаимодействия с большим количеством систем (или большее количество систем должно иметь возможность взаимодействия с ней), включая медицинское оборудование, которое все чаще используется в индустрии, такое, как оборудование для проверки остроты зрения. Это утверждение также обозначает, что должна быть предоставлена возможность достаточно простой адаптации системы OSCAR к требованиям, предъявляемым к системам хранения медицинских данных региональными и федеральными правительствами всех стран мира. Так как каждый регион имеет отличный от других набор законов и требований, это архитектурное решение должно быть ключевых для уверенности в том, что система OSCAR разрабатывается для нужд пользователей со всего мира.

Я также верю в то, что вопрос безопасности должен быть наиболее важным из всех. Система электронных медицинских записей безопасна ровно настолько, насколько безопасен ее наименее защищенный компонент, поэтому особое внимание должно быть уделено вопросу абстрагирования приложения от метода доступа к данным настолько, насколько это возможно таким образом, чтобы оно хранило и получало данные, работая в безопасном окружении и используя основной уровень API доступа к данным, который прошел аудит сторонних лиц и считается подходящим для хранения медицинской информации. Другие системы электронных медицинских записей могут скрывать подробности реализации и использовать закрытый пропиетарный код в качестве меры безопасности (которая на самом деле не является таковой), но так как система OSCAR распространяется в форме открытого исходного кода, она должна возглавлять список наиболее тщательно защищающих данные систем.

Я твердо верю в будущее проекта OSCAR. У нас есть сотни пользователей, о которых мы знаем (а также многие сотни пользователей, о которых нам не известно) и мы получаем важные отчеты о работе приложения от медицинских работников, которые взаимодействуют с нашим проектом ежедневно. Через разработку новых процессов и добавление новых возможностей мы надеемся расширить базу установок и начать поддерживать пользователей из всех других регионов. Нашим намерением движет уверенность в том, что мы предоставляем что-либо, улучшающее жизнь использующих систему OSCAR медицинских работников, а также жизни их пациентов, разрабатывая лучшие инструменты для упрощения работы в сфере здравоохранения.

На главную -> MyLDP -> Тематический каталог ->

Processing.js

Глава 17 из книги "Архитектура приложений с открытым исходным кодом", том 2.

Оригинал: Processing.js
Автор: Mike Kamermans
Дата публикации: 2 Мая 2012 г.
Перевод: А.Панин
Дата перевода: 19 Августа 2013 г.

Изначально разработанный Ben Fry и Casey Reas, язык программирования Processing начал свое развитие в виде языка программирования с открытым исходным кодом (на основе Java), созданного для помощи участникам сообществ дизайнеров и других творческих личностей в изучении программирования в визуальном контексте. Предоставляя значительно упрощенную модель обработки двумерной и трехмерной графики по сравнению с большинством языков программирования, он был быстро адаптирован для выполнения широкого круга задач, начиная с обучения программированию путем разработки простых визуализаций и заканчивая созданием многостенных художественных инсталляций, а также получил возможность выполнения широкого круга задач, от простого считывания последовательности строк до возможности реализации приложения, фактически являющегося интегрированной средой разработки для программирования и управления популярными платами, предназначенными для создания прототипов на основе открытого аппаратного обеспечения под названием "Arduino". Продолжая набирать популярность, Processing твердо занял свое место легко изучаемого и широко используемого языка программирования для создания любых типов визуализаций, а также выполнения большого количества других задач.

Простейшая программа на языке программирования Processing, называемая "скетч" ("sketch"), состоит из двух функций: setup и draw. Первая функция является основной точкой входа и может содержать любое количество инструкций инициализации. После завершения выполнения функции setup программы на языке Processing могут использовать один из следующих вариантов продолжения работы: 1) вызвать функцию draw и запланировать следующий вызов функции draw по истечении фиксированного временного интервала после завершения работы функции; 2) вызвать функцию draw и ждать событий ввода от пользователя. По умолчанию язык программирования Processing использует первый вариант; вызов функции noLoop приводит к использованию второго варианта. Это обстоятельство позволяет использовать два режима представления скетчей, а именно: режим работы в графическом окружении с фиксированной частотой кадров и интерактивный режим, в котором графическое окружение обновляется в ходе обработки событий. В обоих случаях пользовательские события отслеживаются и могут быть обработаны либо с помощью их собственных обработчиков, либо путем установки постоянных значений глобальных переменных напрямую в функции draw для определенных событий.

Processing.js является родственным проектом для проекта Processing, спроектированным с целью его переноса в веб-пространство без необходимости использования виртуальной машины Java или плагинов. Его развитие началось с попытки установления John Resig того, может ли язык Processing быть портирован для использования в веб-пространстве путем работы с новым на тот момент элементом <canvas> из состава HTML5 в роли графического контекста при использовании прототипа библиотеки, представленного в 2008 году. Разработанная с мыслью о том, что "код должен просто работать", библиотека Processing.js совершенствовалась в течение долгих лет с целью предоставления возможности создания визуализаций данных, цифровых произведений искусства, интерактивных анимаций, обучающих графиков, видеоигр и других работ с использованием веб-стандартов без каких-либо плагинов. Вы можете разрабатывать код на языке программирования Processing, используя либо Processing IDE, либо ваш любимый текстовый редактор, интегрировать его в веб-страницу, используя элемент <canvas>, после чего библиотека Processing.js выполнит остальные шаги, выводя необходимые графические данные с использованием элемента <canvas> и позволяя пользователям взаимодействовать с графическими данными таким же образом, каким они могли бы взаимодействовать с обычным обособленным Processing-приложением.

17.1. Как это работает?

Библиотека Processing.js является немного необычной для проекта с открытым исходным кодом, так как ее кодовая база содержится в единственном файле с именем processing.js, который содержит код для реализации функций языка Processing, представленный единственным объектом, реализующим функции всей библиотеки. При обсуждении метода структурирования кода следует упомянуть о том, что мы постоянно перемещаем элементы этого объекта, пытаясь немного улучшить код в каждом релизе. Архитектура библиотеки достаточно проста, а ее функция может быть описана одним предложением; она преобразует исходный код на языке Processing в корректный исходный код на языке JavaScript, причем каждый вызов функции API языка Processing ставится в соответствие подходящей функции объекта преобразования элементов языка Processing в JavaScript, что в итоге приводит к выполнению с элементом <canvas> тех же действий, которые были бы выполнены при использовании языка Processing с канвой апплета Java.

Для повышения скорости работы приложений мы используем два отдельных пути исполнения кода для работы с 2D- и 3D-функциями, при этом при загрузке скетча используется либо первый, либо второй путь для выбора необходимых оберток функций, из чего можно сделать вывод о том, что мы избегаем увеличения затрат ресурсов экземплярами выполняющихся приложений. Однако, при разговоре о структурах данных и направлении выполнения кода, следует учитывать тот факт, что знания в области языка JavaScript подразумевают то, что вы сможете прочитать код файла processing.js, возможно за исключением системы разбора синтаксических структур.

Унификация кода на языках Java и JavaScript

Преобразование кода на языке Processing в код на языке JavaScript обозначает, что вы можете просто сообщить браузеру о том, что нужно выполнить полученный в результате преобразования код и в том случае, если вы выполнили преобразование корректно, он просто заработает. Но уверенность в том, что преобразование выполнено и время от времени также выполняется корректно, требует приложения некоторых усилий. Синтаксис языка программирования Processing основан на синтаксисе языка программирования Java и это значит, что библиотеке Processing.js приходится в общем случае преобразовывать исходный код на языке Java в исходный код на языке JavaScript. Изначально эта процедура выполнялась путем рассмотрения исходного кода на языке Java в форме строки и итерационной замены специфичных для Java подстрок на их аналоги из JavaScript. (Интересующиеся ранней версией системы разбора кода читатели могут найти ее код здесь, причем следует рассматривать фрагмент кода со строки 37 до строки 266). Для небольшого синтаксического набора это решение было приемлемым, но с течением времени и нарастанием сложности оно начало давать сбои. Впоследствии система разбора кода была полностью переписана для добавления возможности построения абстрактного синтаксического дерева (Abstract Syntax Tree - AST) вместо разбора строки, путем изначального разбиения исходного кода на языке Java на функциональные блоки и последующего сопоставления каждого из таких блоков с соответствующими синтаксическими конструкциями языка JavaScript. В результате, хотя и произошло снижение читаемости кода библиотеки Processing.js, в ее составе появился транскомпилятор, преобразующий код на языке Java в код на языке JavaScript в процессе работы приложения. (Читатели могут внимательно изучить этот код вплоть до строки 19217.)

Ниже приведен код скетча на языке Processing:

void setup() {
      size(200,200);
      noCursor();
      noStroke();
      smooth(); }

    void draw() {
      fill(255,10);
      rect(-1,-1,width+1,height+1);
      float f = frameCount*PI/frameRate;
      float d = 10+abs(60*sin(f));
      fill(0,100,0,50);
      ellipse(mouseX, mouseY, d,d); }

И этот же код после преобразования с помощью библиотеки Processing.js:

   function($p) {
        function setup() {
            $p.size(200, 200);
            $p.noCursor();
            $p.noStroke();
            $p.smooth(); }
        $p.setup = setup;

        function draw() {
            $p.fill(255, 10);
            $p.rect(-1, -1, $p.width + 1, $p.height + 1);
            var f = $p.frameCount * $p.PI / $p.__frameRate;
            var d = 10 + $p.abs(60 * $p.sin(f));
            $p.fill(0, 100, 0, 50);
            $p.ellipse($p.mouseX, $p.mouseY, d, d); }
        $p.draw = draw; }

Все это звучит здорово, но существует несколько проблем, которые мешают преобразованию синтаксических конструкций языка Java в синтаксические конструкции языка JavaScript:

  1. Программы на языке Java являются изолированными объектами. Программы на языке JavaScript делят данные с веб-страницей.
  2. Язык Java использует строгую типизацию. JavaScript не использует ее.
  3. Java является основанным на классах и их экземплярах объектно-ориентированным языком программирования. JavaScript не является таковым.
  4. Язык Java использует разделенные переменные и методы. JavaScript не проводит такого разделения.
  5. Язык Java позволяет производить перегрузку методов. JavaScript не предоставляет такой возможности.
  6. Язык Java позволяет производить импорт скомпилированного кода. В рамках JavaScript не вводится даже такого понятия.

Решение этих проблем было компромиссом между тем, что нужно пользователям и тем, что мы можем сделать с помощью веб-технологий. В последующих разделах мы обсудим каждую из этих проблем более подробно.

17.2. Значительные различия

Программы на языке Java работают в своих собственных потоках; программы на языке JavaScript могут заблокировать ваш браузер.

Программы на языке Java являются изолированными объектами, выполняющимися в своих собственных потоках, находящихся в большом наборе выполняемых приложений в вашей системе. Программы на языке JavaScript, напротив, выполняются в браузере и соперничают друг с другом образом, не свойственным обычными приложениями для настольных компьютеров. В тот момент, когда программа на языке Java загружает файл, она ожидает окончания загрузки ресурса, после чего выполнение программы в обычном режиме продолжается. В том случае, когда программа является изолированным объектом, этот подход приемлем. Операционная система может отвечать на действия пользователя, так как она ответственна за планирование выполнения программных потоков и даже в том случае, когда программе требуется час для загрузки всех необходимых ей данных, вы также можете использовать свой компьютер. В случае веб-страницы процесс работы приложения значительно отличается. Если ваша "программа" на языке JavaScript ожидает загрузки ресурса, она заблокирует свой процесс до тех пор, пока ресурс не станет доступен. В том случае, если вы используете браузер, в котором для каждой вкладки создается отдельный процесс, ваша вкладка будет заблокирована, но браузер все равно можно будет использовать. Если вы используете браузер, не создающий таких процессов, он может перестать отвечать на запросы пользователя. Таким образом, независимо от назначения процесса, страница, на которой выполняется сценарий не сможет использоваться до момента загрузки ресурса, причем возможно, что ваш интерпретатор JavaScript полностью заблокирует браузер.

Такое поведение неприемлемо для современных веб-приложений, в которых ресурсы передаются асинхронно и страница должна работать в нормальном режиме в процессе фоновой загрузки ресурсов. Хотя это поведение и свойственно для традиционных веб-страниц, для веб-приложений оно становится реальной головной болью: как вы заставите код на языке JavaScript бездействовать в течение заданного промежутка времени в ожидании загрузки ресурса при условии того, что в рамках языка JavaScript не существует явного механизма для перевода приложения в режим ожидания? Хотя в рамках языка JavaScript также нет явного механизма для работы с потоками, он реализует модель событий и объект XMLHTTPRequest, предназначенный для запроса произвольных (представленных не только в форматах XML или HTML) данных с использованием произвольных строк URL. Этот объект поддерживает несколько различных событий состояния и мы можем использовать его асинхронно для получения данных, причем в процессе браузер будет отвечать на запросы пользователя. Этот объект отлично подходит для программ, в которых осуществляется управление исходным кодом: вы просто останавливаете программу после осуществления запроса данных и возобновляете выполнение программы в момент, когда данные становятся доступны. Однако, это практически невозможно для кода, разработанного в соответствии с идеей синхронной загрузки ресурсов. Вставка "периодов ожидания" в программы, предназначенные для работы в режиме фиксированной частоты кадров не является приемлемой, поэтому нам придется прибегнуть к альтернативным подходам.

Все же в некоторых случаях мы решили применить операции синхронного ожидания. Например, при загрузке файла со строками используется синхронная версия объекта XMLHTTPRequest, которая заблокирует выполнение сценариев страницы до тех пор, пока данные не станут доступны. В других случаях нам приходилось проявлять сообразительность. Для загрузки изображений, например, использовался встроенный механизм загрузки изображений браузера; мы создавали новый объект Image с помощью JavaScript, устанавливали строку URL изображения в качестве значения его атрибута src, после чего браузер выполнял всю остальную работу, уведомляя нас о том, что изображение доступно с помощью события onload. Этот механизм даже не основывается на объекте XMLHTTPRequest, он просто эксплуатирует возможности браузера.

Для упрощения работы в том случае, если вам заранее известно о том, какие изображения должны быть загружены, мы добавили поддержку директив предварительной загрузки и, таким образом, выполнение скетча не будет начато до тех пор, пока процесс предварительной загрузки изображений не будет завершен. Пользователь может задать любое количество изображений для предварительной загрузки с помощью блока комментариев в начале скетча; после этого библиотека Processing.js выполнит предварительную загрузку изображений. Событие onload для каждого из изображений сообщит нам о том, что передача данных изображения закончена и оно подготовлено для вывода (изображение просто загружено, но пока не декодировано в массив пикселей), после чего мы можем загрузить данные в соответствующий объект Processing с именем PImage с указанием корректных значений (width (ширина), height (высота), данные пикселей, и.т.д.) и удалить изображение из списка предварительной загрузки. В тот момент, когда список становится пустым, начинается выполнение скетча и используемые в ходе его выполнения изображения могут использоваться без необходимости ожидания их загрузки.

Ниже приведен пример директив предварительной загрузки:

  /* @pjs preload="./worldmap.jpg"; */

    PImage img;

    void setup() {
      size(640,480);
      noLoop();
      img = loadImage("worldmap.jpg"); }

    void draw() {
      image(img,0,0); }

Для других случаев нам пришлось разработать более запутанные системы "ожидания доставки ресурсов". Шрифты, в отличие от изображений, не имеют соответствующих встроенных в браузер механизмов загрузки (или, по крайней мере, системы, настолько же функциональной, насколько система загрузки изображений). Хотя загрузка шрифтов и может быть осуществлена с помощью правила CSS @font-face, причем она будет осуществляться силами браузера, не существует событий JavaScript для уведомления об окончании загрузки шрифта. Мы наблюдаем медленный процесс добавления в браузеры функций, предназначенных для генерации событий JavaScript, указывающих на завершение процесса загрузки шрифтов, но эти события генерируются "слишком рано", так как браузеру после загрузки шрифта может потребоваться еще от нескольких до нескольких сотен миллисекунд для разбора шрифта перед его использованием на странице. Следовательно, использование этих событий может привести либо к невозможности применения шрифта, либо к применению отличающегося от указанного шрифта в том случае, когда указан шрифт, который должен использоваться в случае ошибки. Вместо использования этих событий мы включили в комплект поставки библиотеки замечательный шрифт формата TrueType, содержащий единственную букву "A" с чрезвычайно малыми метриками и передали браузеру команду для загрузки этого шрифта с помощью правила @font-face со строкой URI, содержащей байтовое представление шрифта в форме строки в формате BASE64. Этот шрифт настолько малого размера, что мы можем быть уверены в том, что он будет доступен немедленно. Для любой другой инструкции загрузки шрифта мы сравниваем метрики текста для желаемого и встроенного шрифтов. Для скрытого тэга <div> установлен атрибут использования желаемого шрифта, при этом в случае ошибки используется встроенный шрифт. В то время, как текст в рамках этого тэга <div> имеет чрезвычайно малые размеры, нам известно, что желаемый шрифт пока не доступен, поэтому мы просто проверяем размеры шрифта через заданные интервалы времени до того момента, как метрики шрифта станут разумными.

На главную -> MyLDP -> Тематический каталог ->

Processing.js

Глава 17 из книги "Архитектура приложений с открытым исходным кодом", том 2.

Оригинал: Processing.js
Автор: Mike Kamermans
Перевод: А.Панин

Язык Java использует строгую типизацию; JavaScript не использует ее.

В языке Java числовые значения 2 и 2.0 отличаются, поэтому при их использовании в математических операциях будут получены различные результаты. Например, при использовании кода i = 1/2 значение переменной i будет равно 0, так как эти значения рассматриваются как целочисленные, но при этом при использовании кода i = 1/2.0, i = 1.0/2 и даже i = 1./2. значение переменной i будет равно 0.5, так как числа рассматриваются как дробные десятичные с ненулевой целой частью и нулевой дробной частью. Даже в том случае, если результирующий тип данных является числовым с плавающей точкой, а в арифметических операциях используются только целочисленные значения, результат также будет целочисленным. Это обстоятельство позволяет вам записывать достаточно сложные математические выражения при использовании языка программирования Java, а, следовательно, и при использовании языка программирования Processing, но они будут генерировать теоретически значительно отличающиеся результаты при переходе к использованию библиотеки Processing.js, так как в рамках языка JavaScript вводится только понятие "чисел". При разговоре о JavaScript следует упомянуть о том, что значения 2 и 2.0 рассматриваются как одно и то же число, что может привести к очень занимательным ошибкам при выполнении скетча с использованием библиотеки Processing.js.

Это может показаться большой проблемой и мы изначально были убеждены именно в этом, но вам не удастся поспорить с реальными отчетами об использовании библиотеки: оказывается, что люди практически никогда не сталкиваются с подобной проблемой при размещении своих скетчей, использующих библиотеку Processing.js, в сети. Вместо решения этой проблемы каким-либо замечательным и творческим способом, было предложено удивительно прямолинейное решение; мы не стали решать ее и, принимая архитектурное решение, мы посчитали ненужным пересмотр сложившегося порядка вещей. Если говорить кратко, мы могли добавить таблицу символов строгой типизации и таким образом получить поддержку несуществующих типов в рамках языка JavaScript для переключения функций в зависимости от типа данных, но эта несовместимость не могла быть корректно устранена без сложной работы по поиску неочевидных ошибок, поэтому вместо добавления большого объема кода и замедления процесса выполнения приложений, мы оставили эту особенность работы библиотеки нетронутой. Это хорошо документированная особенность, поэтому "качественный код" не должен пытаться использовать преимущества явных преобразований типов из языка Java. При этом иногда вы можете забыть об этой особенности и результат работы приложения может оказаться весьма интересным.

Java является основанным на классах и их экземплярах объектно-ориентированным языком программирования с четким разделением между пространствами переменных и методов. JavaScript не является таковым.

JavaScript использует прототипы объектов и соответствующую им модель наследования. Это значит, что все объекты представлены в формате пар ключ/значение, где каждый ключ представлен строкой, а значения являются примитивами, массивами, объектами или функциями. При взгляде со стороны наследования следует отметить, что прототипы могут дополнять другие прототипы, но не существует реальной концепции "суперкласса" и "подкласса". Для добавления возможности исполнения "корректного" объектно-ориентированного кода в Java-стиле нам пришлось реализовать классическую модель наследования для языка JavaScript в рамках библиотеки Processing.js без значительного замедления ее работы (нам кажется, что в этом плане мы добились успеха). Нам также пришлось предложить способ предотвращения коллизий между именами переменных и именами функций. Из-за представления объектов в JavaScript в формате ключ/значение, описание переменной с именем line с последующим описанием функции, подобным line(x1,y1,x2,y2) позволит вам работать с объектом, использующим только то, что было объявлено в последнюю очередь. Сначала JavaScript устанавливает значение object.line = "some value", после чего повторно устанавливает значение переменной line object.line = function(x1,y1,x2,y2)(...), заменяя то значение line, которое вы ожидали увидеть.

Создание механизма раздельного управления переменными и методами/функциями значительно замедлило бы библиотеку, поэтому в документации точно так же описано то, что использование функций и переменных с одинаковыми именами является плохой идеей. В том случае, если бы все разрабатывали "корректный" код, это не стало бы большой проблемой, так как обычно переменные и функции называются в зависимости от того, для чего они предназначены или что они делают, но в реальности все не так. Иногда ваш код не будет работать и это произойдет из-за нашего решения, заключающегося в том, что в случае конфликта имен предпочтительным вариантом является отказ от выполнения кода вместо медленной работы в любом случае. Второй причиной, обуславливающей отсутствие реализации разделения между переменными и функциями является то, что такой подход может привести к неработоспособности кода на языке JavaScript, используемого в скетчах системы Processing. Замыкания и цепочка областей видимости языка JavaScript основываются на характере объектов, представленных парами ключ/значение, поэтому вмешательство в процесс их обработки путем разработки собственных методов управления также могло значительно повлиять на производительность, в особенности в областях компиляции при выполнении и компрессии, использующих замыкания функций.

Язык Java позволяет производить перегрузку методов. JavaScript не предоставляет такой возможности.

Одной из наиболее мощных возможностей языка Java является возможность объявления функции, скажем add(int,int), после которой может быть объявлена другая функция с таким же именем, но отличающимся набором аргументов, т.е, add(int,int,int) или с другими типами аргументов, т.е., add(ComplexNumber,ComplexNumber). При вызове функции add с двумя или тремя целочисленными аргументами автоматически будет вызвана соответствующая функция, а при вызове функции add с аргументами, представленными числами с плавающей точкой или объектами Car, будет сгенерирована ошибка. С другой стороны, в языке JavaScript нет поддержки такой возможности. В JavaScript функция является свойством и вы можете разыменовать ее (в этом случае JavaScript передаст вам значение после приведения типов, которое в данном случае будет логическим значением true, если свойство указывает на описание функции или false в противном случае) или осуществить ее вызов, используя операторы исполнения (которые записываются с помощью скобок, без или с некоторым количеством аргументов в них). Если вы объявите функцию add(x,y), после чего осуществите ее вызов add(1,2,3,4,5,6), описанный код будет приемлем для JavaScript. В качестве значения аргумента x будет установлено число 1, а в качестве значения y - 2, причем все последующие аргументы будут просто проигнорированы. Для того, чтобы сделать механизм перегрузки методов работоспособным, мы заменяем функции с идентичными именами и разным количеством аргументов на пронумерованную функцию, следовательно функция function(a,b,c) в исходном коде будет преобразована в функцию function$3(a,b,c) в результирующем коде и функция function(a,b,c,d) превратится в функцию function$4(a,b,c,d), что позволит использовать корректные пути исполнения кода.

Мы также практически полностью решили проблему перегрузки функций с одинаковым количеством аргументов различных типов, так как типы аргументов могут дифференцироваться средствами языка JavaScript. JavaScript может сообщить тип свойств функций при использовании оператора typeof, который вернет строку number, string, object или function в зависимости от того, что представлено с помощью свойства. Описание var x = 3 с последующим описанием x = '6' приведет к тому, что typeof x вернет строку number после первого описания и строку string после повторного присваивания. Пока функции с одинаковым количеством аргументов отличаются их типами, мы осуществляем их переименование и выбор в зависимости от результата операции typeof. Этот подход не работает в том случае, если функции принимают аргументы типа object, поэтому для таких функций мы используем дополнительную проверку с помощью оператора instanceof (который возвращает имя функции, использованной для создания объекта) для поддержки работоспособности механизма перегрузки методов. Фактически, единственным местом, в котором мы не можем успешно провести транскомпиляцию перегружаемых функций, являются участки кода, на которых в функциях используется одинаковое количество аргументов разных числовых типов. Так как язык JavaScript имеет только один числовой тип, объявления таких функций, как add(int x, int y), add(float x, float y) и add(double x, double y), будут конфликтовать друг с другом. Во всех других случаях, однако, механизм перегрузки методов работает отлично.

Язык Java позволяет осуществлять импорт скомпилированного кода.

Иногда функций языка программирования Processing становится недостаточно и дополнительные функции могут быть получены из библиотеки функций Processing. Она реализована в форме архива с расширением .jarchive и скомпилированным Java-кодом внутри и предоставляет в распоряжение разработчика такие дополнения, как как функции для работы с сетью, обработки аудио- и видеоданных, взаимодействия с аппаратным обеспечением, а также другие экзотические функции, не реализуемые самим языком программирования Processing.

Это является проблемой, так как скомпилированный Java-код является байткодом, используемым виртуальной машиной Java. Данное обстоятельство доставило нам много головной боли: как реализовать импорт функций из библиотек без разработки декомпилятора байткода Java? После длившихся практически в течение года дискуссий мы пришли к по-видимому наиболее простому решению. Вместо того, чтобы пытаться также добавить поддержку библиотек языка программирования Processing, мы решили добавить поддержку ключевого слова import в скетчи и создать API библиотеки дополнительных функций Processing.js, предоставив таким образом разработчикам возможность создания версий их библиотек на языке JavaScript (в том случае, когда эта задача выполнима в условиях работы веб-приложений), таким образом, в том случае, если они разработают библиотеку дополнительных функций, используемую с помощью директивы import prosessing.video, язык программирования Processing будет использовать архив с расширением .jarchive, а библиотека Processing.js вместо этого будет использовать код из файла processing.video.js, поэтому в обоих случаях приложение будет "просто работать". Эти функции предназначены для включения в релиз Processing.js 1.4, а возможность импорта кода библиотек является последней важной функцией, которой не хватало в Processing.js (на данный момент мы поддерживаем ключевое слово import, но только таким образом, что оно удаляется из исходного кода перед его преобразованием) и будет последним важным шагом для достижения совместимости.

На главную -> MyLDP -> Тематический каталог ->

Processing.js

Глава 17 из книги "Архитектура приложений с открытым исходным кодом", том 2.

Оригинал: Processing.js
Автор: Mike Kamermans
Перевод: А.Панин

Для чего использовать язык JavaScript, если он не совместим с Java?

Это не бессмысленный и имеющий множество вариантов ответа вопрос. Наиболее очевидный ответ заключается в том, что интерпретатор языка JavaScript поставляется в составе браузера. Вам не придется "устанавливать" программные компоненты для поддержки языка JavaScript самостоятельно, не требуется плагина для загрузки перед использованием приложений; все необходимое уже здесь. Если вы хотите портировать какое-либо приложение для работы в веб-окружении, вам придется столкнуться с языком JavaScript. При этом, учитывая гибкость языка JavaScript, выражение "столкнуться" на самом деле никак не говорит о том, насколько мощным является язык. Таким образом, одной из причин для выбора языка JavaScript является то, что "этот язык уже здесь". Практически любое интересующее нас устройство на сегодняшний день поставляется с поддерживающим язык JavaScript браузером. Это утверждение не справедливо в случае языка Java, который все реже и реже распространяется в форме предустановленного программного компонента в том случае, если он вообще доступен.

Однако, корректный ответ заключается не в том, что язык JavaScript "не может" выполнять те функции, которые выполняет язык Java; он может выполнять их, но при этом будет работать медленнее. Даже с учетом того, что изначально язык JavaScript не поддерживает некоторые возможности языка Java, следует помнить о том, что он является Тьюринг-полным языком программирования и может эмулировать работу любого другого языка программирования со снижением скорости работы. Технически мы могли бы разработать завершенную реализацию интерпретатора языка Java с наборами объектов String, разделенными моделями переменных и методов, ориентацией на объекты, классы и их экземпляры со строгими иерархиями классов и любыми другими функциями, существующими под солнцем и реализованными работниками компании Sun (или, на сегодняшний день, компании Oracle), но это не то, для чего мы хотим использовать его: библиотека Processing.js предназначена для преобразования кода Processing в характерный для веб-окружения код с применением такого малого объема вспомогательного кода, как это необходимо. Это значит, что хотя мы и приняли решение о поддержке некоторых специфичных для языка Java возможностей, наша библиотека имеет одно весомое преимущество: она работает со встроенным в страницы кодом на языке JavaScript очень и очень хорошо.

Фактически во время встречи между разработчиками проектов Processing.js и Processing в помещении компании Bocoup, расположенной в Бостоне, в 2010 году Ben Fry спросил John Resig о том, почему он использует замену с помощью регулярных выражений и только частичное преобразование кода вместо создания системы разбора кода и компилятора. Ответ от John заключался в том, что для него важно сохранение возможности смешивания пользователями синтаксиса Processing (Java) и JavaScript без необходимости выбора одного из них. Этот изначальный выбор был ключевым для формирования философии проекта Processing.js. Мы выполнили большой объем работы для предоставления возможности использования этого подхода в рамках нашего кода и четко видим результат этой работы при рассмотрении работ всех создателей "классических веб-приложений", которые используют библиотеку Processing.js и никогда не использовали язык программирования Processing, при этом успешно смешивая синтаксические конструкции языков Processing и JavaScript без лишних проблем.

В следующем примере показано, как может функционировать код со смешанными синтаксическими конструкциями из языков JavaScript и Processing.

    // Код на языке JavaScript (должна быть сгенерирована ошибка при использовании в Processing)
    var cs = { x: 50,
               y: 0,
               label: "my label",
               rotate: function(theta) {
                         var nx = this.x*cos(theta) - this.y*sin(theta);
                         var ny = this.x*sin(theta) + this.y*cos(theta);
                         this.x = nx; this.y = ny; }};

    // Код на языке Processing
    float angle = 0;

    void setup() {
      size(200,200);
      strokeWeight(15); }

    void draw() {
      translate(width/2,height/2);
      angle += PI/frameRate;
      while(angle>2*PI) { angle-=2*PI; }
      jQuery('#log').text(angle); // Код на языке JavaScript (ошибка при использовании в Processing)
      cs.rotate(angle);           // Корректный код как на языке JavaScript, так и на языке Processing
      stroke(random(255));
      point(cs.x, cs.y); }

Многие вещи в языке Java являются обещаниями: строгая типизация является обещанием для компилятора, относящимся к данным, область видимости является обещанием того, кто будет вызывать методы и ссылаться на переменные, интерфейсы являются обещаниями о том, что экземпляры классов содержат методы, описанные в рамках интерфейса, и.т.д. Нарушение этих обещаний приведет к жалобам компилятора. Но в том случае, если вы не нарушаете их, что является наиболее важным аспектом архитектуры библиотеки Processing.js, вам не понадобится дополнительного кода для выполнения этих обещаний, чтобы программа работала. Если вы устанавливаете числовое значение переменной и ваш код рассматривает эту переменную как числовую, то в конце концов объявление var varname ничем не хуже объявления int varname. Вам необходима типизация? При использовании языка Java это так; в случае использования JavaScript она не требуется, поэтому зачем принуждать разработчиков использовать ее? Аналогичный подход используется в отношении других обещаний. Если компилятор языка Processing не жалуется на ваш код, мы можем убрать все точные синтаксические конструкции, соответствующие описанным обещаниям, и код все так же будет работоспособен.

Этот подход сделал библиотеку Processing.js чрезвычайно часто используемой при создании визуализаций данных, мультимедийных презентаций и даже развлекательных приложений. Скетчи, созданные с использованием классического синтаксиса языка Processing, работают, при этом скетчи, в которых смешивается синтаксис языков Java и JavaScript, также отлично работают, как впрочем и скетчи, при создании которых используется классический синтаксис языка JavaScript и которые рассматривают библиотеку Processing.js в качестве улучшенного фреймворка для рисования на канве. После приложения участниками проекта усилий к созданию альтернативы классическому языку программирования Processing без жесткого требования исключительного использования синтаксиса языка Java, проект стал использоваться широким кругом пользователей, представленным в масштабе всей сферы веб-технологий. Мы наблюдали примеры использования библиотеки Processing.js в масштабе всей глобальной сети. Все компании, начиная с IBM и заканчивая Google, создавали приложения для визуализации, приложения для создания презентаций и даже простые игры с помощью Processing.js - библиотека Processing.js набирала вес.

Другой замечательной особенностью процесса преобразования кода, использующего синтаксис языка Java в код, использующий синтаксис языка JavaScript, при условии невмешательства в уже существующий код на языке JavaScript является то, что мы реализовали возможность, о которой даже не думали: библиотека Processing.js получила возможность работы с любыми приложениями, которые используют язык JavaScript. Одной из действительно интересных вещей, которую мы наблюдаем на сегодняшний день, например, является то, что люди используют язык CoffeeScript (изящно упрощенный язык программирования похожий на Ruby, в ходе работы которого происходит транскомпиляция кода в код на языке язык JavaScript), комбинируя его с библиотекой Processing.js, и добиваются по истине превосходных результатов. Даже если бы мы намеревались создать "язык программирования Processing для веб-приложений", осуществляющий разбор синтаксических конструкций языка Processing, люди воспользовались бы результатами нашей работы и добавили поддержку совершенно новых синтаксисов. Но они никогда не сделали бы этого в том случае, если бы мы реализовали библиотеку Processing.js в форме простого интерпретатора Java-кода. Используя преобразование кода вместо реализации его интерпретатора, библиотека Processing.js поспособствовала широкому распространению языка Processing в сфере веб-приложений в более значительной степени, чем это могло бы случиться при исключительном использовании синтаксических конструкций языка Java, или даже в том случае, если бы использовался исключительно синтаксис языка Java, но выполнение приложений осуществлялось с привлечением средств языка JavaScript. Использование нашего кода не только конечными пользователями, но и разработчиками, пытающимися интегрировать его со своими технологиями, выглядело удивительно и вдохновляюще. Было ясно то, что мы делаем что-то правильно и сообщество веб-разработчиков довольно результатами нашей работы.

Результат

В преддверии релиза библиотеки Processing.js версии 1.4.0 следует отметить то, что результатом нашей работы уже является библиотека, с помощью которой можно выполнить любой переданный скетч при том условии, что он не импортирует функций из скомпилированных Java-библиотек. Если вы можете разработать приложение на языке Processing, и оно будет корректно функционировать, вы сможете также разместить его на веб-странице и просто запустить его. Из-за различий в методах доступа к аппаратному обеспечению и в низкоуровневых реализациях различных частей конвейера обработки изображений, появятся различия во временных интервалах, но в общем случае скетч, который выводит изображение с частотой 60 кадров в секунду при работе в Processing IDE будет выводить изображение с такой же частотой 60 кадров в секунду при работе на современном компьютере с современным браузером. Мы достигли точки точки развития проекта, в которой число сообщений об ошибках начало сокращаться и большая часть работы заключается не в добавлении новых возможностей, а в исправлении и оптимизации кода.

Благодаря усилиям множества разработчиков, работающих над исправлением более 1800 ошибок, описанных в сообщениях пользователей, скетчи на языке Processing "просто работают" при использовании библиотеки Processing.js. Даже те скетчи, которые импортируют код библиотек могут работать в том случае, если в распоряжении имеется исходный код используемой библиотеки. При благоприятных обстоятельствах библиотека может быть разработана таким образом, что у вас будет возможность преобразовать ее к виду, использующему классический синтаксис языка Processing, путем выполнения нескольких операций поиска и замены строк. В этом случае код может начать работу в виртуальном пространстве незамедлительно. В том же случае, когда библиотека предоставляет такие функции, которые не могут быть реализованы с использованием классического синтаксиса языка Processing, но могут быть реализованы с использованием классического синтаксиса языка JavaScript, потребуется дополнительная работа для эффективной эмуляции библиотеки с помощью кода на языке JavaScript, но портирование все так же возможно. Единственными функциями, при использовании которых код на языке Processing не может быть портирован, являются функции, по своей сути не доступные для браузеров, такие, как функции для непосредственного взаимодействия с аппаратным обеспечением (таким, как веб-камеры или платы Arduino) или функции, выполняющие необрабатываемые операции записи данных на диск, хотя даже эта ситуация меняется. Браузеры постоянно расширяют свои функции для возможности выполнения все более сложных приложений и актуальные на сегодняшний день сдерживающие факторы через год могут исчезнуть, таким образом, можно надеяться на то, что в не очень далеком будущем даже те скетчи, которые на данный момент невозможно запустить в браузере, смогут быть портированы.

17.3. Компоненты кода

Библиотека Processing.js распространяется и разрабатывается в виде одного большого файла, но в плане архитектуры она может быть разделена на три отдельных компонента: 1) загрузчик, ответственный за преобразования исходного кода на языке Processing в исходный код на языке JavaScript с синтаксическими конструкциями из Processing.js и его исполнение, 2) статические функции, которые могут быть использованы всеми скетчами и 3) функции скетчей, которые должны ставиться в соответствие их отдельным экземплярам.

Загрузчик

Загрузчик является программным компонентом, управляющим тремя процессами: процессом предварительной обработки кода, процессом преобразования кода и процессом исполнения скетча.

Предварительная обработка кода

На этапе предварительной обработки кода директивы библиотеки Processing.js выделяются из кода и обрабатываются. Эти директивы могут быть разделены на два типа: установки и инструкции загрузки. Существует небольшое количество директив, оформленных в соответствии с философией "простой работы" и единственный тип установок, которые могут быть изменены авторами скетчей, относятся к взаимодействию со страницей. По умолчанию скетч будет выполняться в том случае, когда страница не находится в фокусе, но директива pauseOnBlur = true устанавливает параметры скетча таким образом, что он будет прерывать исполнение в моменты, когда страница, на которой располагается скетч, уходит из фокуса и восстанавливать работу в момент, когда страница вновь появляется в фокусе. Также по умолчанию клавиатурный ввод перенаправляется скетчу только в том случае, если он находится в фокусе. Это особенно важно в том случае, когда люди выполняют множество скетчей на одной и той же странице, так как клавиатурный ввод, предназначенный для одного из скетчей, не должен обрабатываться другим. Однако, эта функция может быть отключена с помощью директивы globalKeyEvents = true, в результате чего все события от клавиатуры будут передаваться каждому из скетчей, выполняемых на странице.

Инструкции загрузки выполняются в форме вышеописанных предварительных загрузок изображений и шрифтов. Так как изображения и шрифты могут использоваться множеством скетчей, они загружаются и отслеживаются глобально, поэтому различные скетчи не будут пытаться загрузить несколько раз один и тот же ресурс.

Преобразование кода

Компонент преобразования кода формирует ветви дерева абстрактного синтаксического анализа на основе таких элементов исходного кода, как выражения, методы, переменные, классы, и.т.д. После этого данное дерево абстрактного синтаксического анализа преобразуется в исходный код на языке JavaScript, из которого в процессе исполнения формируется эквивалентная скетчу программа. Этот преобразованный исходный код максимально использует экземпляр фреймворка Processing.js для установления отношений классов, причем классы из исходного кода на языке Processing преобразуются в прототипы языка JavaScript со специальными функциями для установления родительских классов и объединениями с функциями и переменными родительских классов.

Исполнение скетча

Финальным этапом процесса загрузки является исполнение скетча, которое начинается с установления того, завершена ли его предварительная загрузка и, в случае ее завершения, продолжается путем добавления скетча в список исполняемых приложений и генерации в рамках скетча характерного для языка JavaScript события с именем onLoad, таким образом, все обработчики событий скетча смогут выполнить необходимые действия. После этого начинается исполнение цепочки функций приложения Processing: сначала вызывается функция setup, а затем - draw и в том случае, если скетч исполняется в цикле, устанавливается интервал времени для вызовов функции draw, причем длительность этого интервала выбирается с учетом максимального приближения частоты вывода изображения к желаемой частоте кадров для скетча.

Статическая библиотека

Большая часть работы библиотеки Processing.js выполняется в рамках "статической библиотеки", которая предоставляет описания констант, универсальных функций и универсальных типов данных. Большая часть этих элементов выполняет двойную работу, так как они описаны в виде глобальных свойств, но при этом также на них приведены ссылки из экземпляров классов для ускорения исполнения кода. Такие глобальные константы, как коды ключей и обозначения цветов размещены в самом объекте языка Processing, их значения устанавливаются однократно, после чего используются ссылки из объектов, созданных с использованием конструктора языка Processing. Такой же подход, позволяющий разрабатывать код в максимальном соответствии с принципом "однократной разработки и повсеместного запуска" без снижения производительности, используется для работы с самостоятельными вспомогательными функциями.

Библиотека Processing.js должна поддерживать большое количество сложных типов данных не только для того, чтобы реализовывать поддержку типов из языка программирования Processing, но также и для осуществления внутренних операций. Эти типы данных также описаны в рамках конструктора Processing:

Администрирование

Последней функцией библиотеки статического кода является поддержание в актуальном состоянии списка выполняющихся на данный момент на странице экземпляров скетчей. Список экземпляров скетчей формируется на основе идентификаторов канвы, используемой при загрузке каждого из скетчей, поэтому пользователи могут осуществить вызов Processing.getInstanceById('идентификатор_канвы') и получить ссылку на свой скетч для взаимодействия с ним.

Код экземпляров классов

Код экземпляров классов разрабатывается в форме объявлений p.functor = function(arg, _) для API языка Processing, и в форме p.constant = _ для переменных состояния скетча (где p является установленной ссылкой на скетч). Ни одно из этих объявлений не находится в отдельных блоках кода. Наоборот, код организован на основе функций, поэтому код, реализующий операции с экземплярами классов PShape размещен в непосредственной близости к объявлению объекта PShape и код для осуществления графических операций с участием экземпляров классов размещается недалеко или в самих объявлениях объектов Drawing2D и Drawing3D.

Для повышения быстродействия большая часть кода, который мог бы быть разработан в форме статического кода с оберткой в рамках экземпляра класса, реализуется исключительно в форме кода экземпляра класса. Например, функция lerpColor(c1, c2, ratio), которая устанавливает цвет, относящийся к процессу работы алгоритма линейной интерполяции двух цветов, объявляется в форме функции экземпляра класса. Вместо того, чтобы использовать вызов p.lerpColor(c1, c2, ratio), являющийся оберткой над какой-либо статической функцией Processing.lerpColor(c1, c2, ratio), в том случае, когда фактически никакая из других частей библиотеки Processing.js не использует функцию lerpColor, код будет выполняться быстрее в том случае, если он будет представлен в форме функции экземпляра класса. Хотя такой подход и увеличивает размер описания класса, большая часть функций, в отношении которых у нас могло бы возникнуть желание о преобразовании их в функции экземпляров классов вместо использования обертки для функции статической библиотеки, представлена функциями малого размера. Следовательно, увеличивая потребление памяти, мы создаем действительно быстрые пути исполнения кода. В то время, как для всего кода объекта Processing при запуске одномоментно резервируется участок памяти размером в 5 MB, необходимый для работы отдельных скетчей код занимает около 500 KB памяти.

17.4. Разработка библиотеки Processing.js

Разработка библиотеки Processing.js ведется в интенсивном темпе в первую очередь из-за того, что процесс разработки подчиняется нескольким основным правилам. Так как эти правила влияют на архитектуру библиотеки Processing.js, стоит кратко рассмотреть их перед завершением этой главы.

Создавать работающий код

Выражение "создание работающего кода" может показаться тавтологическим; вы разрабатываете код, и в результате ваш код либо работает, так как вы именно для этого его и разрабатывали, либо не работает, что подразумевает то, что вы пока не справились с поставленной задачей. Однако, выражение "создание работающего кода" может может быть записано в развернутой форме следующим образом: "создание работающего кода и предоставление доказательств его работоспособности после завершения процесса разработки".

Наряду с многими особенностями процесса разработки, существует одна особенность, позволившая библиотеке Processing.js поддерживать такие высокие темпы развития и заключающаяся в наличии тестов. Любое сообщение об ошибке, требующее вмешательства в код либо путем разработки нового кода, либо путем исправления старого кода, не может быть помечено как исправленное до того момента, пока не будет предоставлен модульный или сравнительный тест, позволяющий сторонним людям проверить не только то, что код начал работать так, как должен, но и то, что код может работать некорректно при определенных изменениях. Для большей части кода в этом случае обычно разрабатывается модульный тест - небольшой фрагмент кода, который вызывает функцию и просто проверяет, возвращает ли функция корректные значения в корректных и некорректных случаях вызова функции. Этот подход позволяет нам не только тестировать присылаемый код, но и производить тестирование с целью поиска регрессий.

Перед тем, как какой-либо код принимается и переносится в стабильную разрабатываемую ветку исходного кода, модифицированная библиотека Processing.js подвергается тестированию с использованием постоянно расширяющегося набора модульных тестов. Значительные исправления и тесты производительности, в частности, обуславливают использование отдельного набора модульных тестов, в отличие от случая с поиском элементов библиотеки, которые работали корректно до внесения изменений. Наличие теста для каждой функции в рамках API библиотеки наряду с тестами для внутренних функций подразумевает то, что по мере развития библиотеки Processing.js не произойдет внезапного нарушения совместимости с предыдущими версиями. Запрет деструктивных изменений API библиотеки заключается в том, что если ни один из тестов не закончился неудачей перед внесением нового или модификацией старого кода, ни один из тестов также не должен закончиться неудачей в случае использования измененного кода.

Ниже приведен пример модульного теста для проверки внутреннего процесса создания объекта.

    interface I {
      int getX();
      void test(); }

    I i = new I() {
      int x = 5;
      public int getX() {
        return x; }
      public void test() {
        x++; }};

    i.test();

    _checkEqual(i.getX(), 6);
    _checkEqual(i instanceof I, true);
    _checkEqual(i instanceof Object, true);

В дополнение к регулярному использованию модульных тестов, мы также проводим сравнительное визуальное тестирование (или "ref"-тесты). Так как библиотека Processing.js представляет собой порт языка программирования, предназначенного для визуального представления данных, некоторые типы тестирования не могут осуществляться исключительно с помощью модульных тестов. Тестирование с целью установления того, использованы ли корректные пиксели для изображения эллипса, или изображена ли вертикальная линия толщиной в один пиксель четко или с использованием сглаживания, не может быть проведено без визуального сравнения. Так как все распространенные браузеры реализуют элемент <canvas> и API Canvas2D с небольшими отличиями, эти вещи могут быть протестированы только путем запуска кода в браузере и проверки того, что результирующее изображение, сформированное скетчем, выглядит аналогично изображению, сгенерированному при использовании языка программирования Processing. Для упрощения жизни разработчиков мы используем для этого набор автоматических тестов, в котором новые варианты тестов запускаются с использованием языка программирования Processing для генерации "эталонных" данных, которые впоследствии будут использоваться для попикслельного сравнения. После этого описанные данные сохраняются в форме комментария в скетче, с помощью которого они генерировались, формируя тест, а сами эти тесты впоследствии выполняются с использованием библиотеки Processing.js на странице для визуального сравнительного тестирования, причем при выполнении каждого теста производится попиксельное сравнение между тем "как должен выглядеть результат теста" и тем, "как он выглядит в реальности". Если значения пикселей отличаются, тест завершается неудачей и разработчику предоставляются три изображения: как должен выглядеть результат теста, как результат был сформирован средствами библиотеки Processing.js и изображение с указанием различий между двумя изображениями путем обозначения проблемных областей с помощью пикселей красного цвета, а областей корректных данных - белого. Как и в случае модульных тестов, эти тесты должны завершиться успешно перед приемом любого стороннего кода.

Создавать быстрый код

В ходе работы над проектом с открытым исходным кодом, создание работоспособной функции является всего лишь первым шагом в рамках ее жизненного цикла. Как только вы добиваетесь работоспособности функции, появляется желание проверки того, что она работает быстро. Основываясь на принципе, формулируемом следующим образом: "если вы не сможете проверить быстродействие функции, вы не сможете усовершенствовать ее", большая часть функций из состава библиотеки Processing.js снабжается не только ref-тестами, но и тестами производительности (или "perf"-тестами). Небольшие фрагменты кода, которые просто вызывают функцию без проверки корректности ее работы, выполняются по нескольку сотен раз подряд, причем время их выполнения сохраняется на специальной странице тестирования производительности. Это позволяет оценить, насколько хороша (или плоха!) производительность библиотеки Processing.js в браузерах, поддерживающих элемент <canvas> из состава HTML5. Всегда после прохождения патчем для оптимизации модульного и сравнительного тестирования, он также проходит тестирование с помощью нашей страницы тестирования производительности. JavaScript является любопытным языком и замечательный код фактически может работать в несколько раз медленнее, чем код, содержащий большее количество тех же строк, оформленных в виде внутреннего кода вместо отдельной вызываемой функции. Это делает процесс тестирования производительности чрезвычайно важным. Нам удалось повысить производительность определенных частей библиотеки в три раза, просто обнаружив часто используемые циклы в ходе тестирования производительности и сократив количество вызовов функций путем формирования внутреннего кода, а также путем преобразования функций для возврата значений в тот момент, когда они становятся известны вместо возврата значения в самом конце функции.

Другим способом, которым мы пытаемся увеличить производительность библиотеки Processing.js, является исследование возможностей ее рабочего окружения. Так как библиотека Processing.js жестко зависима от производительности интерпретаторов JavaScript, имеет смысл также рассмотреть то, какие возможности различные интерпретаторы предоставляют для ускорения работы сценариев. Это актуально особенно сегодня, когда браузеры начинают поддерживать функции аппаратного ускорения графических операций, ведь в том случае, если интерпретаторы предоставляют новые и более эффективные типы данных и функции для выполнения необходимых для работы Processing.js низкоуровневых операций, становятся возможными мгновенные увеличения производительности. Например, язык JavaScript по техническим причинам не использует статическую типизацию, но окружения для разработки приложений, взаимодействующих с аппаратным обеспечением для работы с графикой, используют ее. Раскрывая используемые для непосредственного взаимодействия с аппаратным обеспечением структуры данных на уровне языка JavaScript, становится возможным значительное повышение производительности фрагментов кода в том случае, если нам известно то, при их работе будут использоваться определенные значения.

Создавать краткий код

Существует два способа сокращения объема кода. Во-первых, это разработка компактного кода. Если вы осуществляете многократные манипуляции с переменной, постарайтесь сократить их в одну операцию (если это возможно). В том случае, если вы осуществляете многократный доступ к переменной объекта, осуществляйте кэширование. Если вы вызываете функцию несколько раз, кэшируйте результат. Осуществляйте выход из функции как только вы получаете всю необходимую информацию и в общем случае применяйте все приемы, которые применил бы оптимизатор кода, самостоятельно. Определенно, язык программирования JavaScript замечательно подходит для этого, так как он очень гибок. Например, вместо использования конструкции:

if ((result = functionresult)!==null) {
  var = result;
} else {
  var = default;
}

В JavaScript можно использовать:

var = functionresult || default

Также существует другая форма увеличения компактности кода, которая связана с процессом его исполнения. Так как язык JavaScript позволяет вам изменять связки функций в процессе работы приложения, выполняемый код значительно уменьшается в размере в том случае, если вы скажете: "привязать функцию рисования двумерных линий к вызову функции line" сразу после того, как узнаете, что программа работает в двумерном, а не в трехмерном режиме, следовательно, вам не придется использовать условный переход

if(mode==2D) { line2D() } else { line3D() }

в каждой функции, которая может работать как в двумерном, так и в трехмерном режиме.

Наконец, существует процесс минимизации объема кода. Существует множество качественных систем, которые позволяют сжать ваш код на языке JavaScript путем переименования переменных, удаления пробелов и применения определенных оптимизаций кода, которые сложно произвести вручную при условии его последующей читаемости. Примерами таких систем являются YUI minifier и Google closure compiler. Мы используем эти технологии в рамках библиотеки Processing.js для увеличения пропускной способности каналов пользователей - минимизация кода, достигаемая путем удаления комментариев, соответствует уменьшению размера библиотеки на целых 50%, при этом в случае использования преимущества процесса взаимодействия современных серверов и браузеров путем передачи сжатых с помощью gzip данных, мы можем получить полнофункциональную сжатую библиотеку Processing.js в размером в 65 KB.

Если ничего не получается, обратиться к людям

Не все функции, реализованные на данный момент в языке Processing могут работать в браузере. Модели систем безопасности предотвращают выполнение определенных действий, таких, как сохранение файлов на жесткий диск и осуществление операций ввода/вывода с использованием последовательных портов и портов USB, также отсутствие типизации в языке JavaScript может привести к непредсказуемым последствиям (таким, как выполнение всех математических действий с использованием чисел с плавающей точкой). Иногда мы сталкивались с выбором между вариантом добавления огромного объема кода для работы в частном случае и вариантом добавления отметки "невозможно исправить" к сообщению об ошибке. В таких случаях создавалось новое сообщение об ошибке, обычно с названием "Добавить документацию, в которой приводится объяснение причин...".

Для того, чтобы не забывать об этих случаях, у нас есть документация, предназначенная для людей, начавших использовать библиотеку Processing.js и обладающих опытом работы с языком программирования Processing, а также для людей, которые начали использовать библиотеку Processing.js, обладая опытом работы с языком программирования JavaScript, охватывающая различия между тем, что ожидается и тем, что на самом деле происходит. Некоторые особенности просто заслуживают отдельного упоминания, так как вне зависимости от того, сколько усилий мы приложили к разработке библиотеки Processing.js, остаются функции, которые мы не можем добавить в библиотеку без ущерба пользовательским качествам. Хорошая архитектура затрагивает не только вопросы о том, как реализуются функции, но и вопросы о том, почему они так реализуются; без ответов на эти вопросы у вас будут возникать одни и те же дискуссии на тему того, почему код выглядит определенным образом и должен ли он выглядеть по-другому каждый раз после смены команды разработчиков.

17.5. Выученные уроки

Наиболее важный выученный нами в ходе разработки библиотеки Processing.js урок заключается в том, что при портировании языка программирования важным является то, что в результате приложения работают корректно, а не то, идентичен ли используемый вашим портом код оригиналу. Несмотря на то, что синтаксис языков Java и JavaScript чрезвычайно похож и преобразование кода на языке Java в код на языке JavaScript достаточно просто, обычно следует обращать внимание на то, что можно сделать средствами самого языка JavaScript для получения того же функционального результата. Использование преимущества отсутствия типизации данных путем повторного использования переменных, использование определенных встроенных функций, которые быстры в JavaScript, но медленны в Java или пренебрежение шаблонами программирования, которые быстры в Java, но медленны в JavaScript, может привести к тому, что ваш код будет радикально отличаться, но работать абсолютно так же. Вам часто приходилось слышать от людей предостережения об изобретении колеса, но эти слова относятся только к тем случаям, когда используется один и тот же язык программирования. При портировании же следует изобретать столько колес, сколько понадобится для достижения требуемой производительности.

Другой важный урок заключается в том, что следует всегда завершать выполнение функций так рано, как это возможно и максимально сократить количество ветвлений. Блок if/then с последующим оператором возврата значения функции return может работать (иногда значительно) быстрее в случае использования вместо него конструкции if-return/return, причем в ней оператор возврата значения будет использоваться как условная ссылка. Хотя концептуально верным решением и является полное установление состояния функции перед возвратом результата выполнения этой функции, это также означает, что есть вероятность выполнения кода, который никак не относится к возвращаемому значению. Не теряйте циклы ЦП; возвращайте результат выполнения функции сразу после того, как у вас в распоряжении находится вся необходимая информация.

Третий урок касается тестирования вашего кода. В начале разработки библиотеки Processing.js мы воспользовались преимуществом наличии очень качественной документации, описывающей то, как язык программирования Processing "должен" работать, а также большого набора тестов, большая часть из которых в то время "неудачно выполнялась по известным причинам". Это обстоятельство позволило нам сделать две вещи: 1) разрабатывать код для прохождения тестов и 2) создавать тесты перед разработкой кода. В ходе обычного процесса разработки, когда разрабатывается код вместе с соответствующими тестами, на самом деле создаются предвзятые тесты. Вместо проверки того, выполняет ли ваш код необходимые действия в соответствии со спецификацией, вы проверяете только то, не содержит ли ваш код ошибок. При разработке библиотеки Processing.js мы начали создавать тесты, основываясь не на том, какие требования предъявляются к определенной функции или набору функций, а на документации для них. Обладая этими непредвзятыми тестами, мы можем разрабатывать функционально завершенный код вместо просто корректного, но, возможно, не функционирующего кода.

Последний урок является также наиболее общим: применяйте правила гибкой методологии разработки также и к отдельным исправлениям. Ни для кого не будет полезно, если вы начнете разработку и в течении трех дней не будете взаимодействовать с окружающими людьми, прорабатывая идеальное решение. Вместо этого приведите ваше решение в форме кода в работоспособное состояние, причем не обязательно для всех тестовых случаев, и попросите составить отчеты об использовании. Работая в одиночку и применяя набор тестов для устранения ошибок, вы не получаете гарантии того, что будет создан качественный и завершенный код. Никакое количество тестов не поможет вам узнать о том, что вы забыли разработать тесты для определенных частных случаев или о том, что существует лучший алгоритм по сравнению с тем, что вы выбрали, или даже о том, что вам необходимо поменять местами объявления для улучшения подготовки кода к JIT-компиляции. Рассматривайте исправления как релизы: представляйте общественности исправления как можно раньше, обновляйте их как можно чаще и превращайте отчеты об использовании программного компонента в усовершенствования.

18.1. Введение

Puppet является инструментом для управления IT-инфраструктурой, разработанным с использованием языка программирования Ruby и используемым для автоматизации обслуживания датацентров и управления серверами компаний Google, Twitter, Нью-Йоркской фондовой биржи и многих других организаций. Главным образом развитие проекта поддерживается организацией Puppet Labs, которая положила начало его развитию. Puppet может управлять серверами в количестве от 2 до 50,000 и обслуживаться командой, состоящей из одного или сотен системных администраторов.

Puppet является инструментом, предназначенным для настройки и поддержания режима работы ваших компьютеров; используя его простой язык описания конфигурации, вы можете описать для Puppet то, как вы хотите сконфигурировать свои машины, после чего он изменит их конфигурацию в случае необходимости для достижения соответствия вашей спецификации. По мере того, как вы будете изменять эту спецификацию с течением времени под воздействием таких обстоятельств, как обновления пакетов, добавление новых пользователей или обновления конфигурации, Puppet автоматически обновит конфигурацию ваших машин для соответствия спецификации. В случае, если они уже сконфигурированы должным образом, Puppet не будет выполнять никакой работы.

В общем случае Puppet выполняет все возможные действия направленные на то, чтобы использовать функции существующей системы для выполнения своей работы; т.е., в дистрибутивах, основанных на технологиях компании RedHat, он будет использовать утилиту yum для управления пактами и init.d для управления службами, при этом в операционной системе OS X он будет использовать утилиту dmg для управления пактами и launchd для управления службами. Одной из основополагающих целей проекта Puppet является выполнение полезной работы вне зависимости от того, используется ли для этого код проекта Puppet или сама система, поэтому следующие системные стандарты являются критичными.

Проект Puppet создан с учетом опыта использования множества других инструментов. В мире приложений с открытым исходным кодом наибольшее влияние на его развитие оказал проект CFEngine, который являлся первым инструментом конфигурации общего назначения с открытым исходным кодом, а также проект ISconf, который использовал утилиту make для выполнения всей работы, что в свою очередь обусловило особое внимание к явно описанным зависимостям в системе. В мире коммерческого программного обеспечения Puppet может рассматриваться как конкурент проектов BladeLogic и Opsware (которые впоследствии были приобретены более крупными компаниями), каждый из которых успешно продавался в момент появления Puppet, но при этом каждый из этих инструментов продавался руководителям больших компаний, вместо развития в соответствии с непосредственными требованиями к качественным инструментам системных администраторов. Предназначением проекта Puppet было решение аналогичных решаемым этими инструментами проблем, при этом он был предназначен для совершенно других пользователей.

В качестве простого примера метода использования Puppet, ниже приведен фрагмент кода, который позволяет быть уверенным в правильной установке и конфигурации службы безопасной оболочки (SSH):

class ssh {
    package { ssh: ensure => installed }
    file { "/etc/ssh/sshd_config":
        source => 'puppet:///modules/ssh/sshd_config',
        ensure => present,
        require => Package[ssh]
    }
    service { sshd:
        ensure => running,
        require => [File["/etc/ssh/sshd_config"], Package[ssh]]
    }
}

Этот код позволяет быть уверенным в том, что пакет будет установлен, файл будет размещен в необходимом месте и служба будет запущена. Следует отметить, что мы описали зависимости между ресурсами, поэтому всегда будем выполнять любую работу в корректной последовательности. Этот класс может быть ассоциирован с любым узлом для применения заданной конфигурации в рамках этого узла. Обратите внимание на то, что строительными блоками конфигурации Puppet являются структурированные объекты, в данном случае это объекты package, file и service. В терминологии Puppet мы называем эти объекты ресурсами (resources) и любые спецификации конфигурации Puppet состоят из этих ресурсов и зависимостей между ними.

Нормальная установка Puppet будет содержать десятки или даже сотни этих фрагментов кода, называемых нами классами (classes); мы храним эти классы на диске в файлах, называемых манифестами (manifests), а также объединяем логически связанные классы в рамках групп, называемых модулями (modules). Например, вы можете иметь в распоряжении модуль ssh с этим классом ssh и любыми другими логически связанными классами наряду с модулями mysql, apache и sudo.

Большая часть операций взаимодействия с Puppet осуществляется с использованием командной оболочки или постоянно работающих служб HTTP, но существуют и графические интерфейсы для выполнения таких задач, как обработка отчетов. Компания Puppet Labs также предоставляет коммерческие программные продукты для работы с Puppet, которые используют графические веб-интерфейсы.

Первый прототип Puppet был разработан летом 2004 года, а полноценная разработка проекта началась в феврале 2005 года. Изначально он был спроектирован и разработан Luke Kanies, системным администратором, имеющим большой опыт разработки небольших инструментов, но не имеющим опыта разработки проектов, содержащих более 10,000 строк кода. По существу Luke Kanies получил навыки программирования в ходе разработки проекта Puppet, что отразилось на архитектуре проекта как положительно, так и отрицательно.

Puppet разрабатывался изначально и в первую очередь как инструмент для системных администраторов, облегчающий их жизнь, позволяющий выполнять работу быстрее, более эффективно и с меньшим количеством ошибок. Первой ключевой инновацией для реализации этого принципа были описанные выше ресурсы, являющиеся примитивами Puppet; они могут переноситься между операционными системами, при этом абстрактно представляя детали реализации, позволяя пользователю думать о результатах работы, а не о том, как их достичь. Этот набор примитивов был реализован на уровне абстракции ресурсов Puppet (Puppet's Resource Abstraction Layer).

Ресурсы Puppet должны быть уникальными для заданного узла. Вы можете иметь в распоряжении только один пакет с именем "ssh", одну службу с именем "sshd" и один файл с именем "/etc/ssh/sshd_config". Это ограничение предотвращает взаимные конфликты между различными частями ваших конфигураций и вы узнаете об этих конфликтах на раннем этапе процесса конфигурации. Мы ссылаемся на эти ресурсы по их типам и именам, т.е., Package[ssh] и Service[sshd]. Вы можете использовать пакет и службу с одним и тем же именем, так как они относятся к различным типам, но не два пакета или службы с одним и тем же именем.

Второй ключевой инновацией в Puppet является возможность прямого указания зависимостей между ресурсами. Ранее используемые инструменты ставили своей целью выполнение индивидуальных задач без рассмотрения взаимосвязей между этими задачами; Puppet был первым инструментом, который явно устанавливал то, что зависимости являются первостепенной частью ваших конфигураций, которые, в свою очередь, должны моделироваться соответствующим образом. Он создавал граф ресурсов и их зависимостей в качестве одного из основных типов данных и практически все действия Puppet зависели от этого графа (называемого каталогом (Catalog)), его вершин и ребер.

Последним важным компонентом Puppet является язык конфигурации. Этот язык является декларативным и предназначен в большей степени для описания конфигурации, чем для полноценного программирования - он практически полностью повторяет формат конфигурации Nagios, но также был создан под значительным влиянием языков из состава CFEngine и Ruby.

В основе функциональных компонентов Puppet лежат два направляющих его развитие принципа: он должен быть настолько простым в использовании, насколько это возможно, причем предпочтение должно отдаваться удобству в использовании, а не возможностям; а также он должен разрабатываться в первую очередь в форме фреймворка и во вторую - приложения, таким образом при желании сторонние разработчики получат возможность создавать свои приложения на основе программных компонентов Puppet. Было понятно, что в дополнение к фреймворку необходимо также широко применяемое качественное приложение, но в первую очередь разработчики все равно занимались фреймворком, а не приложением. Многие люди все еще считают, что Puppet является этим самым приложением, а не фреймворком, на основе которого оно реализовано.

После создания первого прототипа Puppet, Luke стал в целом неплохим Perl-разработчиком с небольшим опытом разработки сценариев оболочки и небольшим опытом работы с языком C, большей частью полученным при работе с системой CFEngine. В дополнение к этому он имел опыт создания систем разбора данных для простых языков, который был получен при разработке двух таких систем для работы в составе небольших инструментов, а также повторной разработки с нуля системы разбора данных для CFEngine с целью упрощения ее поддержки (этот код не был передан проекту из-за небольших несовместимостей).

Решение об использовании динамического языка для реализации Puppet было принято достаточно быстро ввиду значительно более высокой производительности труда разработчика и распространения данного типа языков, но выбор самого оказался достаточно сложным. Начальные прототипы на языке Perl были отвергнуты, поэтому проводились эксперименты для поиска других языков. Была предпринята попытка использования языка Python, но Luke посчитал это язык значительно противоречащим его взгляду на мир. Услышав рассказ друга о преимуществах нового языка, Luke попробовал использовать язык Ruby и за четыре часа создал работающий прототип. В момент, когда началась полномасштабная разработка Puppet, язык Ruby был практически не известен, поэтому решение о его использовании было сопряжено с большим риском, но в этом случае производительность труда разработчика снова сыграла решающую роль в выборе языка. Главной отличительной чертой языка Ruby, по крайней мере от Perl, была простота создания неиерархических отношений классов, при этом язык не противоречил мыслительной деятельности разработчика Luke, что было критично.

18.2. Обзор архитектуры

Этот раздел в первую очередь посвящен описанию архитектуры реализации Puppet (т.е., описанию кода, который мы использовали для выполнения инструментом Puppet возложенных на него задач), но стоит также кратко обсудить архитектуру самого приложения (т.е., принцип взаимодействия его отдельных частей), ведь способ реализации приложения очень важен.

Инструмент Puppet был разработан для выполнения задач в двух режимах: в клиент/серверном режиме с центральным сервером и агентами, выполняющимися на отдельных узлах и в режиме без использования сервера, в котором отдельный процесс выполняет всю работу. Для достижения совместимости между этими режимами в рамках Puppet всегда использовался принцип внутренней сетевой прозрачности, поэтому при работе в двух режимах использовались одни и те же пути исполнения кода вне зависимости от того, осуществлялось ли взаимодействие посредством сети или нет. Для каждого исполняемого файла может быть установлен подходящий режим локального или удаленного доступа, но во всем остальном они будут вести себя идентично. Также следует отметить то, что вы можете использовать режим без сервера аналогично клиент-серверной конфигурации путем отправки файлов каждому клиенту и их последующего непосредственного разбора. В этом разделе будет описан клиент-серверный режим работы, так как он более прост для понимания ввиду рассмотрения отдельных компонентов, но следует помнить и о том, что информация из этого раздела также справедлива в случае работы в режиме без использования сервера.

Одно из определяющих архитектурных решений в рамках архитектуры приложения Puppet заключается в том, что клиенты не должны иметь доступ к модулям Puppet; вместо этого они должны получать спецификацию конфигурации, скомпилированную специально для них. Данный подход имеет множество достоинств: во-первых, вы будете следовать принципу снижения привилегий, в соответствии с которым каждый узел располагает только той информацией, которая предназначена для него (как он должен быть сконфигурирован), но не имеет доступа к информации о конфигурации других серверов. Во-вторых, вы можете полностью разделить операции и требования прав, необходимые для компиляции спецификации конфигурации (для этого может потребоваться доступ к централизованным хранилищам данных) и применения этой конфигурации. В-третьих, вы можете использовать отсоединенные узлы, на которых будет поддерживаться постоянная конфигурация без взаимодействия с центральным сервером, значит, конфигурация ваших серверов будет соответствовать спецификации даже в том случае, когда сервер прекратит работу и клиент отсоединится (как это происходит в случае мобильной установки или работы клиентов в "демилитаризованной зоне" сети (DMZ)).

Учитывая возможность этого выбора, процесс работы становится достаточно прямолинейным:

  1. Процесс агента Puppet собирает информацию об узле, на котором он выполняется, и отправляет ее серверу.
  2. Система разбора данных использует эту системную информацию и располагающиеся на локальном диске модули Puppet для компиляции спецификации конфигурации для этого определенного узла, после чего возвращает ее агенту.
  3. Агент применяет полученную спецификацию конфигурации локально, таким образом изменяя локальное состояние узла, и заполняет отчет о результатах при содействии сервера.

Потоки данных в Puppet
Рисунок 18.1: Потоки данных в Puppet

Управление потоком данных между процессами и компонентами Puppet
Рисунок 18.2: Управление потоком данных между процессами и компонентами Puppet

Таким образом, агент имеет доступ к информации о своей системе, ее конфигурации и каждому отчету, который он генерирует. Сервер располагает копиями всех этих данных и к тому же имеет доступ ко всем модулям Puppet и любым базам данных и службам, которые могут потребоваться для компиляции спецификации конфигурации.

Помимо всех компонентов, которые участвуют в формировании описанного потока данных и о которых мы поговорим позже, существует множество типов данных, используемых Puppet в ходе выполнения операций внутреннего взаимодействия. Эти типы данных являются критичными, так как с их помощью осуществляются все взаимодействия и они являются публично раскрываемыми типами данных, которые могут принимать или генерировать любые другие инструменты.

Наиболее важными типами данных являются:

Помимо наборов фактов, манифестов, каталогов и отчетов, Puppet поддерживает типы данных для работы с файлами, сертификатами (которые он использует для аутентификации), а также некоторые другие.

18.3. Анализ компонентов

Агент (Agent)

Первым компонентом, с которым вы можете столкнуться при запуске Puppet, является процесс agent. Традиционно он начинал работу после запуска отдельного исполняемого файла с именем puppetd, но в версии 2.6 мы приняли решение о сокращении количества исполняемых файлов до одного, поэтому на данный момент он может быть запущен с помощью команды puppet agent, по аналогии с тем, как работает Git. Сам по себе агент реализует небольшое количество функций; в первую очередь это функции управления конфигурацией и код, реализующий описанные выше аспекты работы на стороне клиента.

Инструмент сбора фактов (Facter)

Следующим после агента программным компонентом является внешнее приложение, называемое инструментом сбора фактов, который является по сути очень простым приложением для сбора информации об узле, на котором работает. Собираются такие данные, как название операционной системы, IP-адрес узла и имя узла, при этом функции инструмента сбора фактов достаточно просто расширяются, поэтому множество организаций добавляет свои собственные плагины для получения дополнительных данных. Агент отправляет данные, собранные инструментом сбора фактов, серверу, после чего последний вступает в рабочий процесс.

Внешний классификатор узла (External Node Classifier)

Первым компонентом, с которым мы столкнемся на стороне сервера, является внешний классификатор узла или ENC. Этот компонент принимает имя узла и возвращает простую структуру данных, содержащую высокоуровневую спецификацию конфигурации для данного узла. Внешний классификатор узла обычно является отдельной службой или приложением: это сделано для взаимодействия с другим проектом с открытым исходным кодом, таким, как Puppet Dashboard или Foreman, или для интеграции с существующими хранилищами данных, такими, как LDAP. Целью этого компонента является установление того, к каким функциональным классам принадлежит заданный узел и того, какие параметры должны быть использованы для конфигурации этих классов. Например, заданный узел может принадлежать классам debian и webserver и иметь параметр datacenter со значением atlanta.

Следует отметить, что для версии 2.7 Puppet компонент ENC не является обязательным; вместо его использования пользователи могут непосредственно описать конфигурации узлов с помощью кода Puppet. Поддержка компонента ENC была добавлена примерно через 2 года после выпуска первого релиза Puppet, так как мы поняли, что процесс классификации узлов фундаментально отличается от процесса их конфигурации, что делает более осмысленным разделение инструментов для решения этих задач, нежели расширение функций языка для поддержки возможности решения обоих задач. ENC всегда является рекомендуемым компонентом и на некотором этапе развития проекта станет необходимым (в тот момент, когда в составе Puppet будет поставляться достаточно удобный для использования компонент и это требование не будет усложнять работу).

После того, как сервер получает классификационную информацию от внешнего классификатора узла и системную информацию от инструмента сбора фактов (посредством агента), он добавляет эту информацию в объект Node и передает его компилятору.

Компилятор (Compiler)

Как упоминалось ранее, в составе Puppet реализован специальный язык для описания конфигураций систем. В реальности компилятор этого языка состоит из трех частей: похожая на Yacc система разбора, генерации и лексического анализа кода; группа классов, используемая для построения нашего дерева абстрактного синтаксического анализа (Abstract Syntax Tree - AST); и класс компилятора Compiler, который управляет взаимодействием всех этих классов, а также представляет API для этой части системы.

Наиболее сложный аспект работы компилятора заключается в том, что большая часть кода конфигурации Puppet загружается по необходимости после первого обращения (для сокращения времени загрузки и пресечения возможности появления не соответствующих действительности, касающихся отсутствующих и не нужных зависимостей записей в журнале), что подразумевает отсутствие явных вызовов, позволяющих загрузить код и произвести его разбор.

Система разбора кода Puppet использует обычный, похожий на Yacc генератор систем разбора кода, созданный с использованием инструмента с открытым исходным кодом Racc. К сожалению, в момент начала разработки проекта Puppet не существовало генераторов лексических анализаторов с открытым исходным кодом, поэтому используется лексический анализатор собственной разработки.

Так как мы используем в Puppet дерево абстрактного синтаксического анализа, каждое объявление из набора грамматических конструкций Puppet преобразуется в экземпляр класса дерева синтаксического анализа (т.е., Puppet::Parser::AST::Statement) вместо непосредственного выполнения соответствующего действия и эти экземпляры классов AST компонуются в форме дерева по мере сокращения дерева грамматических конструкций. Это дерево абстрактного синтаксического анализа позволяет улучшить производительность в том случае, если на единственном сервере будут компилироваться конфигурации для многих сторонних узлов, так как становятся возможными однократный разбор кода и многократная компиляция. Также у нас появляется возможность выполнять обзор внутренних структур дерева абстрактного синтаксического анализа, что позволяет получить информацию и возможности, которыми мы не обладали бы в случае непосредственного разбора кода без преобразований.

В начале развития проекта Puppet было доступно только несколько подходящих примеров методов построения дерева абстрактного синтаксического анализа, поэтому используемый метод прошел множество этапов эволюционного развития и в итоге мы, по-видимому, пришли к его относительно уникальной версии. Вместо создания одного дерева абстрактного синтаксического анализа для всей конфигурации, мы создаем множество небольших деревьев, доступ к которым осуществляется на основе имен. Например, этот код:

class ssh {
    package { ssh: ensure => present }
}

создает новое дерево абстрактного синтаксического анализа, содержащее одни экземпляр класса Puppet::Parser::AST::Resource и сохраняет это дерево под именем "ssh" в хэш-таблице для всех классов этого определенного окружения. (Я не буду рассматривать подробности реализации других связанных с классами конструкций, так как эта информация не является обязательной для продолжения данного описания).

При наличии дерева абстрактного синтаксического анализа и объекта описания узла Node (полученного от внешнего классификатора узла), компилятор может выбрать классы, описанные в рамках данного объекта (конечно же, если таковые имеются), найти и обработать их. В ходе этой обработки компилятор занимается построением дерева пространств действия переменных; каждый класс получает свое собственное пространство действия переменных, которое объединяется с пространством создающего его класса. Основной принцип создания динамических пространств действия переменных в рамках Puppet: если одни класс включает в себя другой класс, то включенный класс может напрямую работать с переменными включившего его в свой состав класса. Такое поведение всегда было кошмаром для разработчиков, поэтому мы прорабатывали пути избавления от этой возможности.

Дерево пространств действия переменных (Scope tree) является временным и уничтожается после завершения компиляции, но в ходе компиляции также постепенно формируется один артефакт. Мы называем этот артефакт каталогом (Catalog) и он является всего лишь графом, представляющим ресурсы и их взаимодействия. В этом каталоге не сохраняются никакие описания переменных, управляющих структур или вызовов функций; все, что там хранится - это необработанные данные, которые могут быть достаточно просто сконвертированы в форматы JSON, YAML или в любые другие.

Во время компиляции мы формируем данные об отношении ресурсов к соответствующим классам; класс "содержит" все ресурсы, которые используются этим классом (т.е., описанный выше пакет ssh содержится в классе ssh). Класс может содержать описание, которое само содержит либо дополнительные описания, либо отдельные ресурсы. Каталог по большей части является очень горизонтальным несвязанным графом: он содержит множество классов, глубина каждого из которых обычно не превышает нескольких уровней.

Одной из причиняющих неудобства особенностей данного графа является то, что но также содержит отношения "зависимостей", представленных такими данными, как описания службы требующей пакет (может быть, это сделано из-за того, что при установке пакета на самом деле создается служба), но эти отношения зависимостей фактически указываются в форме значений параметров ресурсов вместо вершин в структуре графа. Наш класс графа (с именем SimpleGraph, присвоенным по историческим причинам) не предоставляет возможности создания вершин для содержащихся ресурсов и зависимостей в рамках одного графа, поэтому нам приходится производить преобразования между ними с различными целями.

Транзакция (Transaction)

После того, как формирование каталога завершено (будем считать, что ошибки не произошло), он будет передан с помощью транзакции. В системе с разделенными клиентом и сервером, транзакция выполняется на стороне клиента, который принимает каталог, используя для этого протокол HTTP, как показано на Рисунке 18.2.

Класс транзакции системы Puppet является фреймворком для фактического вмешательства в работу системы, в то время, как все описанные нами ранее компоненты просто участвуют в процессе формирования и передачи объектов. В отличие от транзакций, которые выполняются в таких свойственных для них системах, как базы данных, транзакции системы Puppet не обладают такими свойствами, как атомарность.

Транзакция используется для выполнения относительно простой задачи: эта задача заключается в обходе графа, фактически представляющего различные взаимоотношения компонентов и проверке того, что состояние каждого из ресурсов было синхронизировано. Как было сказано выше, в ходе транзакции приходится преобразовывать вершины графа содержащихся ресурсов (т.е., вершины, указывающие, например, на то, что класс Class[ssh] содержит пакет Package[ssh] и службу Service[sshd]) в вершины графа зависимостей (т.е., в вершины, указывающие на то, что служба Service[sshd] зависит от пакета Package[ssh]), после чего выполняется стандартная топологическая сортировка графа с выбором каждого из ресурсов по очереди.

В отношении заданного ресурса мы осуществляем простой процесс обработки, состоящий из трех шагов: получение текущего состояния этого ресурса, сравнение его с желаемым состоянием и осуществление любых изменений, необходимых для устранения расхождений. Например, при использовании следующего кода:

file { "/etc/motd":
    ensure => file,
    content => "Welcome to the machine",
    mode => 644
}

в ходе транзакции производится проверка содержимого и прав доступа к файлу /etc/motd, а в том случае, если они не совпадают с указанными, производятся изменения либо одного, либо обоих параметров. Если в файловой системе по пути /etc/motd каким-либо образом оказалась директория, будет сделана резервная копия всех файлов этой директории, после чего она будет удалена и заменена на файл с соответствующим содержимым и правами доступа.

Этот процесс изменения состояния системы фактически производится средствами простого класса ResourceHarness, в рамках которого полностью описан интерфейс между классами транзакции Transcation и ресурса Resource. Данный подход позволяет снизить количество соединений между классами и упрощает внесение изменений в самостоятельные классы.

Уровень абстракции ресурсов (Resource Abstraction Layer)

Класс транзакции является ключевым механизмом, участвующим в выполнении основной работы системы Puppet, но на самом деле вся работа выполняется на уровне абстракции ресурсов, который также является наиболее интересным компонентом Puppet с точки зрения его архитектуры.

Уровень абстракции ресурсов был первым компонентом, созданным в рамках проекта Puppet, и, в отличие от языка, он точно описывает то, что может сделать пользователь. Задачей этого компонента является определение назначения ресурса и способа выполнения работы в рамках системы с использованием ресурсов, а язык Puppet специально создан для указания ресурсов с использованием модели, понятной уровню абстракции ресурсов. Поэтому это также наиболее важный компонент системы, который сложнее всего изменить. Существует огромное количество вещей, которые нам хотелось бы изменить в рамках уровня абстракции ресурсов и мы уже реализовали множество критических улучшений данного компонента в течение многих лет (наиболее важным из них было добавление классов Providers), но, несмотря на это, большой объем работы в рамках этого компонента придется выполнить в будущем.

На уровне подсистемы компилятора мы создаем модели ресурсов и их типов с использованием отдельных классов (названных соответственно Puppet::Resouce и Puppet::Resource::Type). Наша цель состоит в том, чтобы использовать эти классы также на уровне абстракции ресурсов, но на сегодняшний день модели этих двух элементов (ресурс и тип) создаются с использованием единственного класса Puppet::Type. (Класс назван некорректно из-за того, что он был создан задолго до того, как мы начали использовать термин "ресурс", в то время, когда мы использовали непосредственную сериализацию структур из памяти для осуществления взаимодействия между узлами, поэтому было достаточно сложно изменить имена классов.)

Во время создания класса Puppet::Type казалось разумным размещение данных ресурса и его типа в едином классе; кроме того, ресурсы являются всего лишь экземплярами типов ресурсов. Со временем, однако, стало понятно, что отношение между ресурсом и его типом не достаточно хорошо смоделировано в понятиях традиционной структуры наследования. Например, типы ресурсов описывают то, какие параметры может иметь ресурс, а не то, принимает ли он параметры (они все их принимают). Следовательно, наш базовый класс Puppet::Type реализует на уровне классов поведение, призванное установить поведение типов ресурсов, а также поведение на уровне экземпляров классов, направленное на установление их поведения. Также его задачей является управление регистрацией и получением типов ресурсов; если вам нужен тип "user", вы можете осуществить вызов Puppet::Type.type(:user).

Это смешение типов поведения значительно затрудняет поддержку кода класса Puppet::Type. Весь класс состоит менее чем из 2,000 строк кода, но функционирует на трех уровнях - ресурсов, типов ресурсов и управления типами ресурсов, что делает его очень запутанным. Становится абсолютно понятно, из-за чего он является главной целью рефакторинга, но с помощью него не производится взаимодействия с пользователем, поэтому обычно сложно выделить ресурсы на его рефакторинг вместо непосредственной реализации новых возможностей.

Уровнем ниже класса Puppet::Type в рамках уровня абстракции ресурсов находятся классы двух основных типов, наиболее интересный из которых мы называем Providers. На начальном этапе разработки уровня абстракции ресурсов в каждом типе ресурса происходило смешение описания параметра с кодом, который реализовывал функции управления. Например, мы могли объявить параметр "content", после чего реализовать метод, с помощью которого можно будет прочитать содержимое файла, а также другой метод, с помощью которого можно будет модифицировать содержимое этого файла:

Puppet::Type.newtype(:file) do
    ...
    newproperty(:content) do
        def retrieve
            File.read(@resource[:name])
        end
        def sync
            File.open(@resource[:name], "w") { |f| f.print @resource[:content] }
        end
    end
end

Этот пример значительно упрощен (в том смысле, что мы используем контрольные суммы при выполнении внутренних операций с файлами вместо строк с содержимым), но, несмотря на это, вы все равно получите необходимое представление о работе класса.

Использование необходимой модели управления ресурсами с учетом всего их многообразия, со временем стало невозможным. Проект Puppet на данный момент поддерживает 30 типов систем управления пакетами и было бы невозможно поддерживать все их средствами единственного типа ресурса Package. Вместо этого мы реализуем понятный интерфейс для описания типа ресурса. Предоставляющие свойства классы реализуют методы установки и получения значений для всех свойств типов ресурсов, названные очевидным образом. Например, ниже приведен образец класса, предоставляющего описанное свойство:

Puppet::Type.newtype(:file) do
    newproperty(:content)
end
Puppet::Type.type(:file).provide(:posix) do
    def content
        File.read(@resource[:name])
    end
    def content=(str)
        File.open(@resource[:name], "w") { |f| f.print(str) }
    end
end

При этом приходится затрагивать больший объем кода даже в простых случаях, но код гораздо проще понимать и поддерживать, особенно в том случае, когда возрастает либо количество свойств, либо количество классов, предоставляющих свойства.

В начале этого раздела я упоминал о том, что транзакция на самом деле не затрагивает систему напрямую, а вместо этого использует уровень абстракции ресурсов для взаимодействия с ней. Сейчас понятно, что предоставляющие свойства классы на самом деле выполняют необходимую работу. Фактически в общем случае только предоставляющие свойства классы реально вмешиваются в работу системы. Из транзакции поступает запрос содержимого файла и предоставляющий свойства класс считывает его; транзакция указывает на то, что содержимое файла должно быть изменено и предоставляющий свойства класс изменяет его. Следует отметить, однако, что предоставляющий свойства класс никогда самостоятельно не принимает решение о вмешательстве в работу системы - принятие решений происходит на уровне транзакции, после чего предоставляющие свойства классы выполняют работу. Это позволяет транзакции полностью контролировать систему без необходимости понимания аспектов работы с файлами, пользователями или пакетами и это разделение позволяет реализовать в рамках Puppet режим симуляции работы, при использовании которого можно с высокой вероятностью гарантировать то, что на систему не будет оказано воздействия.

Второй основной класс в рамках уровня абстракции ресурсов ответственен за сами параметры. Фактически мы поддерживаем три типа параметров: метапараметры, которые влияют на все типы ресурсов (т.е., любые ресурсы, которые вы можете использовать в режиме симуляции); параметры, являющиеся значениями, не копируемыми на диск (т.е, значениями, к примеру, указывающими на то, следует ли переходить по ссылкам в файлах); и свойства, при использовании которых моделируются аспекты поведения ресурса с изменением данных на диске (т.е., они могут отражать содержимое файла или состояние службы). Разница между свойствами и параметрами особенно сильно сбивает с толку людей, но если вы просто рассматриваете свойства как методы получения и установки значений в рамках классов, эта разница становится очевидной.

Создание отчетов (Reporting)

По мере обхода графа в ходе выполнения транзакции и использования уровня абстракции ресурсов для изменения системной конфигурации, происходит постепенное формирование отчета. Этот отчет в большей степени состоит из событий, генерируемых в ходе осуществления изменений в системе. События же, в свою очередь, предоставляют всестороннее отражение выполняемой работы: они содержат отметку времени, соответствующую времени изменения ресурса, предыдущее значение, новое значение, любое сгенерированное сообщение и отметку о том, успешным или безуспешным оказалось изменение (или о том, что активирован режим симуляции).

Эти события создаются в объекте ResourceStatus, который связан с каждым из ресурсов. Следовательно, для заданной транзакции у вас будет вся информация об использованных ресурсах и о любых произведенных изменениях наряду со всеми метаданными этих изменений, которые могут потребоваться вам.

По завершении транзакции происходит расчет и сохранение в отчете некоторых простейших характеристик, после чего отчет отправляется серверу (в случае соответствующей настройки). После отправки отчета процесс конфигурации считается завершенным и агент снова преходит в режим ожидания, либо его процесс просто завершается.

18.4. Инфраструктура

Сейчас, после того, как мы получили четкое представление о том, как и какие действия выполняет Puppet, стоит потратить немного времени на обсуждения компонентов кода, которые не выступают в роли отдельных систем, реализующих возможности, но при этом критичны для выполнения работы.

Плагины

Одной из замечательных характеристик Puppet является значительная гибкость. Существует по крайней мере 12 различных способов расширения возможностей Puppet, причем большинство этих способов предназначено для использования практически любым пользователем. Например, вы можете создать специальные плагины для работы в следующих областях:

Однако, распределенная природа Puppet обуславливает необходимость агентов в способе получения и загрузки новых плагинов. Следовательно, при каждом запуске Puppet в первую очередь загружаются все доступные серверу плагины. Эти плагины могут являться новыми типами ресурсов или предоставляющими свойства классами, а также новыми фактами или даже новыми обработчиками отчетов.

Это позволяет значительно усовершенствовать агенты Puppet без изменения основных пакетов Puppet. Данная возможность особенно полезна при использовании специализированных установок Puppet.

Фреймворк Indirector

Вы наверняка уже поняли, что у нас существует традиция некорректного именования классов Puppet и, по мнению множества людей, этот случай заслуживает награды. Indirector является относительно стандартным фреймворком, построенным на основе принципа инверсии управления, со значительными возможностями расширения функций. Системы, созданные на основе принципа инверсии управления, позволяют отделить развертываемые функции от метода управления используемыми функциями. В случае Puppet это обстоятельство позволяет нам работать с множеством плагинов, предоставляющих различные функции, предназначенными для таких целей, как доступ к компилятору по протоколу HTTP или его загрузка в процессе работы, а также осуществлять переключение между ними при помощи небольших изменений конфигурации вместо вмешательства в код. Другими словами, фреймворк Indirector из состава Puppet является реализацией шаблона проектирования "service locator", описанного на странице Wikipedia с названием "Инверсия управления". Все взаимодействия одного класса с другим осуществляются посредством фреймворка Indirector с применением похожего на REST интерфейса (т.е., мы поддерживаем методы find, search, save и destroy), при этом переключение режима работы Puppet с бессерверного на клиент/серверный - в большей степени вопрос конфигурации агента таким образом, чтобы он использовал протокол HTTP в качестве конечной точки для получения каталога вместо конечной точки для доступа к компилятору.

Из-за того, что рассматриваемый фреймворк построен на принципе инверсии управления, в соответствии с которым конфигурация строго отделена от путей исполнения кода, этот класс может также сложно поддаваться отладке, особенно в том случае, если вы устанавливаете, причину использования определенного пути исполнения кода.

Сетевые взаимодействия

Прототип Puppet был разработан летом 2004 года, когда главным вопросом относительно сетевого взаимодействия был вопрос о том, следует ли использовать XMLRPC или SOAP, Мы выбрали XMLRPC и этот протокол работал хорошо, но мы столкнулись с большей частью проблем, известных всем другим его пользователям: он не обуславливал использование стандартных интерфейсов для взаимодействия компонентов и в результате очень быстро приобрел излишнюю сложность. Мы также столкнулись со значительными проблемами в отношении использования памяти, так как кодирование данных для использования протокола XMLRPC подразумевало то, что каждый объект как минимум несколько раз размещался в памяти, что очень скоро привело к значительным затратам памяти при работе с файлами большого объема.

В рамках релиза 0.25 (работа над которым началась в 2008 году) мы начали процесс перевода всех сетевых взаимодействий на использование похожей на REST модели, но мы выбрали значительно более запутанный путь, чем простое изменение метода сетевого взаимодействия. Мы разработали фреймворк Indirector для использования в качестве стандартного фреймворка для межкомпонентного взаимодействия и сделали конечные точки REST одним из возможных вариантов осуществления этого взаимодействия. Реализация полной поддержки REST растянулась на два релиза и мы пока не до конца преобразовали код для использования формата JSON (вместо YAML) во всех операциях сериализации. Мы предприняли переход к использованию JSON по двум основным причинам: во-первых, обработка структур формата YAML средствами языка Ruby происходит очень медленно, при этом обработка структур формата JSON происходит значительно быстрее; во-вторых, большая часть веб-приложений переходит на использование JSON и наблюдается тенденция создания более переносимых реализаций библиотек для работы с форматом JSON в отличие от формата YAML. Конечно же, в случае проекта Puppet первые варианты данных в формате YAML не были переносимы между языками, а также обычно не были переносимы между различными версиями Puppet, так как они в основном создавались в результате сериализациии внутренних объектов Ruby.

В нашем следующем основном релизе Puppet мы наконец окончательно удалим код поддержки протокола XMLRPC.

18.5. Выученные уроки

Говоря о реализации, мы гордимся различными типами разделения, которые применены в Puppet: язык полностью отделен от уровня абстракции ресурсов, транзакция не может напрямую повлиять на систему и на уровне абстракции ресурсов не принимается самостоятельных рабочих решений. Это наделяет разработчика приложений значительными возможностями контроля над процессом работы приложения, а также открывает доступ к информации о том, что и почему происходит.

Потенциал расширения возможностей и конфигурации Puppet также очень важен, ведь любой человек может создавать приложения на основе Puppet без лишних сложностей и вмешательства во внутренние функции. Мы всегда реализуем возможности приложения с использованием тех же интерфейсов, которые мы рекомендуем использовать нашим пользователям.

Простота и легкость использования Puppet всегда были приоритетными направлениями развития. Проект все еще достаточно сложно развернуть и запустить, но этот процесс значительно проще тех, что необходимы для введения в строй других аналогичных инструментов. За простоту приходится расплачиваться инженерными решениями, особенно в форме поддержки и дополнительной работы, связанной с проектированием, но это стоит того, ведь важно позволить пользователям заниматься своими задачами вместо задач, связанных с используемым инструментом.

Возможности конфигурации Puppet по истине замечательны, но мы немного увлеклись их реализацией. Существует очень много способов объединения компонентов Puppet и очень просто создать на основе Puppet работающий инструмент, который не удовлетворит вас в итоге. Одной из основных долгосрочных целей является значительное сокращение количества параметров в рамках конфигурации Puppet для того, чтобы пользователь без сложностей не мог осуществить некорректную настройку и мы могли со временем упростить процесс обновления без беспокойства о нестандартных ситуациях.

Мы также очень медленно реализуем значительные изменения. Существуют важные рефакторинги, которые мы планировали провести в течение многих лет, но никогда не проводили. В итоге наши пользователи будут работать с более стабильной системой в краткосрочной перспективе, но при этом осложнится поддержка системы и внесение изменений в ее код.

Наконец, нам потребовалось слишком много времени для того, что бы понять, что наши цели, заключающиеся в упрощении системы, лучшим образом формулируются с использованием языка архитектуры. Как только мы начали говорить об архитектуре вместо абстрактного упрощения, мы обзавелись гораздо лучшим фреймворком для принятия решений о добавлении и удалении возможностей с лучшими способом взаимодействия для обоснования этих решений.

18.6. Заключение

Puppet является одновременно простой и сложной системой. Эта система состоит из множества работающих частей, но они достаточно слабо связаны друг с другом и каждая из них значительно изменилась с момента начала развития проекта в 2005 году. Это фреймворк, который может использоваться для решения любых типов задач, связанных с конфигурацией, но при этом также простое и доступное приложение.

Наш будущий успех заключается в развитии простого и стабильного фреймворка и поддержании простоты использования приложения в ходе расширения его возможностей.

19.1. Немного истории

Python является динамическим языком программирования высокого уровня. Он был создан голландским программистом Guido van Rossum в конце 1980 годов. Оригинальная реализация языка программирования от Guido представляет собой традиционный интерпретатор байткода, разработанный с использованием языка программирования C и впоследствии известный под именем CPython. На сегодняшний день существует также множество других реализаций Python. Среди наиболее известных реализаций можно выделить Jython, которая разработана с использованием языка программирования Java и позволяет осуществлять взаимодействие с кодом Java, IronPython, которая разработана с использованием языка программирования C# и позволяет взаимодействовать с фреймворком .Net от компании Microsoft, а также PyPy, которая будет рассматриваться в данной главе. CPython является все еще наиболее широко используемой реализацией и на сегодняшней день единственной реализацией, которая поддерживает синтаксические конструкции Python 3, следующего поколения языка программирования Python. В рамках данной главы будут описаны архитектурные решения, принятые в ходе разработки PyPy и отличающие эту реализацию от других реализаций языка Python и более того, от любых других реализаций динамических языков.

19.2. Обзор PyPy

При разработке PyPy использовался только язык программирования Python, за исключением немногочисленных заглушек на языке C. Дерево исходного кода проекта PyPy содержит два основных компонента: интерпретатор языка Python и набор инструментов для преобразования кода RPython. Интерпретатор языка Pyhon является применяемым разработчиками окружением времени исполнения, используемым людьми при вызове реализации языка Python под названием PyPy. Фактически оно разработано с использованием подвида языка Python с именем Restricted Python (Python с ограничениями; обычно для его обозначения используется аббревиатура RPython). Цель разработки интерпретатора языка Python с использованием языка RPython заключается в реализации возможности подачи выходных данных интерпретатора на вход второй основной части PyPy, являющейся набором инструментов преобразования кода RPython. Инструмент преобразования кода RPython принимает код на языке RPython и преобразует его в код на выбранном языке более низкого уровня, наиболее часто этим языком является C. Это обстоятельство позволяет PyPy быть самодостаточной реализацией, что подразумевает использование для разработки того языка программирования, поддержка которого реализуется. Как мы увидим в данной главе, инструмент преобразования кода RPython также делает PyPy фрейворком для реализации динамических языков программирования общего назначения.

Мощные абстракции PyPy делают ее наиболее гибкой реализацией языка Python. Она поддерживает около 200 параметров конфигурации, которые позволяют осуществлять действия начиная с выбора реализации сборщика мусора и заканчивая изменением параметров различных оптимизаций процесса преобразования кода.

19.3. Интерпретатор языка Python

Так как RPython является подвидом языка Python и строго соответствует его синтаксису, интерпретатор языка Python проекта PyPy может работать поверх другой реализации языка Python без преобразования кода. Конечно же, он будет работать чрезвычайно медленно, но при этом появляется возможность быстрого тестирования изменений в интерпретаторе. Также это обстоятельство позволяет использовать обычные инструменты отладки для языка Python для отладки интерпретатора. Большинство тестов интерпретатора PyPy может быть выполнено как при работе интерпретатора без преобразования кода, так и при работе интерпретатора с преобразованием кода. Это позволяет проводить быстрое тестирование в процессе разработки, а также проверять то, что интерпретатор при осуществлении преобразования кода ведет себя также, как и интерпретатор без преобразования кода.

По большей части детали реализации интерпретатора языка Python проекта PyPy схожи с деталями реализации интерпретатора CPython; интерпретаторы PyPy и CPython используют практически идентичные представления байткода и структуры данных в ходе интерпретации. Основным отличием между ними является то, что PyPy использует интересную абстракцию под названием "пространства объектов" ("object spaces" или сокращенно "objspaces"). Пространство объектов инкапсулирует данные, необходимые для представления и управления типами данных языка Python. Например, выполнение бинарной операции с двумя объектами Python или получение атрибута объекта в полной мере обрабатывается пространством объектов. Это обстоятельство позволяет освободить интерпретатор от необходимости получения любых деталей реализации объектов Python. Интерпретатор байткода рассматривает объекты Python как черные ящики и вызывает методы пространства объектов в любой момент, когда сталкивается с необходимостью осуществления манипуляций с ними. Например, ниже приведена простейшая реализация кода операции BINARY_ADD, который вызывается при комбинировании двух объектов с помощью оператора +. Обратите внимание на то, что операнды не проверяются интерпретатором; все действия по обработке объектов немедленно делегируются пространству объектов.

def BINARY_ADD(space, frame):
    object1 = frame.pop() # извлечение левого операнда из стека
    object2 = frame.pop() # извлечение правого операнда из стека
    result = space.add(object1, object2) # осуществление операции
    frame.push(result) # запись результата в стек

Абстракция пространства объектов имеет множество преимуществ. Она позволяет получать от объектов или передавать объектам новые реализации типов данных без модификации интерпретатора. Также ввиду того, что единственный способ осуществления манипуляций с объектами связан с использованием пространства объектов, на уровне пространства объектов могут осуществляться вмешательство, буферизация или запись операций с объектами. В ходе использования мощной абстракции пространств объектов в рамках PyPy были проведены эксперименты по внедрению технологии переключения (thunking), при использовании которой вычисление результатов может быть отложено, но осуществляться полностью прозрачно по требованию, а также технологии создания исключений (tainting), при использовании которой любая операция с объектом будет вызывать генерацию исключения (что полезно при передаче важных данных с использованием кода, вызывающего недоверие). Наиболее важный способ применения пространства объектов, однако, будет обсуждаться в Разделе 19.4.

Пространство объектов, используемое в оригинальной версии интерпретатора PyPy, называется стандартным пространством объектов (standard objspace или std objspace для краткости). В дополнение к абстракции, предоставляемой системой пространства объектов, стандартное пространство объектов предоставляет новый уровень абстракции: один и тот же тип данных может иметь множество реализаций. При его использования операции с типами данных осуществляются с применением мультиметодов. Это позволяет выбирать наиболее эффективное представление заданного набора данных. Например, тип long в рамках языка Python (очевидно, целочисленный тип данных большой разрядности) может быть представлен в виде стандартного целочисленного значения размером в одно машинное слово в том случае, если это значение достаточно мало. Более затратная в плане памяти и вычислений реализация типа данных произвольной точности большой разрядности должна использоваться только в случае необходимости. Существует даже реализация целочисленного типа данных Python на основе маркированных указателей. Контейнерные типы также могут быть специализированными по отношению к определенным типами данных. Например, в рамках PyPy реализован словарь (тип хэш-таблицы в Python), предназначенный для работы со строковыми ключами. Тот факт, что один и тот же тип данных может быть представлен различными реализациями, позволяет абсолютно прозрачно использовать объекты из кода уровня приложения; словарь для хранения строк идентичен словарю общего назначения и позволяет корректно отклонять нестроковые значения в случае их добавления.

В PyPy производится разделение между кодом уровня интерпретатора (interp-level) и кодом уровня приложения (app-level). Код уровня интерпретатора, который используется для реализации большей части интерпретатора, должен быть разработан с использованием языка RPython и подвергаться преобразованию. Он напрямую взаимодействует с пространством объектов и объектами Python в специальных обертках. Код уровня приложения всегда обрабатывается с помощью интерпретатора байткода PyPy. Ввиду простоты кода уровня интерпретатора на языке RPython по сравнению с кодом на языке C или Java, разработчики посчитали наиболее простым решением использование кода уровня приложения в некоторых частях интерпретатора. Следовательно, PyPy поддерживает встраивание кода уровня приложения в интерпретатор. Например, функции объявления print языка Python, с помощью которого производится запись данных объектов в поток стандартного вывода, реализованы на уровне приложения Python. Встроенные модули также могут быть разработаны с частичным использованием кода уровня интерпретатора и приложения.

19.4. Инструменты преобразования кода для языка RPython

Набор инструментов для языка RPython предназначен для осуществления нескольких фаз преобразования кода, в ходе которых код на языке RPython преобразовывается в код на целевом языке, обычно C. Высокоуровневое представление фаз преобразования кода показано на Рисунке 19.1. Сами инструменты для преобразования кода разработаны с использованием языка Python (без ограничений) и тесно связаны с интерпретатором PyPy по причинам, которые будут освещены совсем скоро.

Шаги преобразования кода
Рисунок 19.1: Шаги преобразования кода

Первой операцией, которую выполняет инструмент для преобразования кода, является загрузка программы на языке RPython в адресное пространство процесса. (Эта операция осуществляется с использование стандартных для языка Python директив, предназначенных для поддержки операций загрузки модулей). Язык RPython налагает ряд ограничений на стандартные динамические функции языка Python. Например, функции не могут создаваться в процессе работы программы и одна и та же переменная не имеет возможности хранить значения несовместимых типов, примером которых могут служить целочисленное значение и объект. Несмотря на это, в момент начальной загрузки программы инструментом преобразования кода, она обрабатывается обычным интерпретатором языка Python и может использовать все динамические функции языка Python. Интерпретатор языка Python из состава PyPy является программой значительного размера, разработанной с использованием языка RPython, которая эксплуатирует эту возможность для реализации функций метапрограммирования. Например, она генерирует код для управления мультиметодами в стандартном пространстве объектов. Единственное требование заключается в том, что программа должна использовать корректные для языка RPython синтаксические конструкции перед началом новой фазы преобразования кода средствами соответствующего инструмента.

Инструмент преобразования кода строит потоковые графы, отражающие программу на языке RPyton, в ходе процесса с названием "абстрактная интерпретация" ("abstract interpretation"). В ходе абстрактной интерпретации происходит повторное использование интерпретатора языка Python из состава PyPy для интерпретации программ на языке RPython при наличии специального пространства объектов, называемого потоковым пространством объектов (flow objspace). Повторимся, что интерпретатор языка Python рассматривает объекты в программе как черные ящики, обращаясь к пространству объектов для выполнения любой операции. Потоковое пространство объектов вместо стандартного набора объектов языка Python работает только с двумя типами объектов: переменными и константами. Переменные представляют значения, не известные в момент преобразования кода, а константы, что не удивительно, представляют неизменные значения, которые известны в данный момент. Потоковое пространство объектов обладает базовыми возможностями сворачивания констант; если требуется выполнить операцию, все аргументы которой являются константами, в рамках него будет осуществлено статическое вычисление значения. Требования к неизменности значений и указания на необходимость использования констант в рамках языка RPython обозначаются значительно шире, чем в стандартном языке Python. Например, модули, которые несомненно изменяемы в рамках языка Python, представляются константами в потоковом пространстве объектов, так как они не существуют в рамках языка RPython и должны быть свернуты в константы в потоковом пространстве объектов. По мере того, как интерпретатор языка Python интерпретирует байткод функций языка RPython, потоковое пространство объектов записывает операции, выполнение которых требуется. В рамках него осуществляется запись данных для всех конструкций условных ветвлений. Конечный результат абстрактной интерпретации функции является потоковым графом, состоящим из связанных блоков, причем каждый блок содержит одну или большее количество операций.

Прейдем к примеру процесса генерации потокового графа. Рассмотрим простую функцию вычисления факториала:

def factorial(n):
    if n == 1:
        return 1
    return n * factorial(n - 1)

Потоковый граф для функции выглядит следующим образом:

Потоковый граф для функции вычисления факториала
Рисунок 19.2: Потоковый граф для функции вычисления факториала

Функция вычисления факториала была разделена на блоки, содержащие операции, записанные в рамках потокового пространства объектов. Каждый блок имеет входные аргументы и список операций с переменными и константами. Первый блок содержит выбор перехода в конце, который позволяет установить, к какому блоку перейдет управление после завершения выполнения первого блока. Решение о выходе может приниматься на основе значения какой-либо переменной или на основе того, было ли сгенерировано исключение в ходе выполнения последней операции блока. Передача управления осуществляется в соответствии с линиями между блоками.

Потоковый граф, сгенерированный в потоковом пространстве объектов, представлен в статической единичной форме назначения (static single assignment form или SAA), являющейся промежуточном представлением, обычно используемым в компиляторах. Ключевая возможность представления SAA заключается в том, что значение каждой переменной присваивается единожды. Это свойство упрощает реализацию многих преобразований и оптимизаций, осуществляемых компиляторами.

После завершения генерации графа функции начинается фаза генерации аннотации. Инструмент создания аннотаций устанавливает типы результатов и аргументов для каждой из операций. Например, описанная выше функция вычисления факториала в аннотации будет принимать и возвращать целочисленные значения.

Следующая фаза называется типизацией (RTyping). В ходе типизации используется информация от инструмента создания аннотаций для того, чтобы преобразовать каждую из высокоуровневых операций, отображенных в рамках графа потоков данных, в низкоуровневые операции. Это первая часть процесса преобразования кода, для которой имеет значение выбранная система генерации кода. На основе системы генерации кода происходит выбор специфичного для программы инструмента типизации (RTyper). На данный момент инструмент типизации поддерживает две системы типов: низкоуровневую систему типов для систем генерации кода на таких языках, как C, а также высокоуровневую систему типов с классами. Высокоуровневые операции и типы языка Python приводятся в соответствие с выбранной системой типов. Например, для операции add с операндами, представленными целочисленными значениями в аннотации, будет сгенерирована операция int_add с низкоуровневой системой типов. Более сложные операции, такие, как поиск в хэш-таблицах, генерируют вызовы функций.

После типизации производятся некоторые оптимизации низкоуровневого потокового графа. Эти оптимизации по большей части являются такими стандартными применяемыми компиляторами оптимизациями, как сворачивание констант, удаление лишних операций резервирования памяти, а также удаление неиспользуемого кода.

Обычно код на языке Python периодически использует операции динамического резервирования памяти. Язык RPython, являясь производным языка Python, наследует этот шаблон проектирования, связанный с интенсивным резервированием памяти. Однако, в большинстве случаев эти резервирования памяти являются временными и локальными в рамках функций. Удаление операций резервирования памяти (malloc removal) является оптимизацией, призванной бороться с этими случаями. С помощью этой оптимизации производится удаление описанных операций резервирования памяти путем "разделения" ранее динамически созданного объекта на компоненты в том случае, если это возможно.

Для того, чтобы увидеть процесс удаления операций резервирования памяти в работе, предположим, что данная функция вычисляет Евклидово расстояние между двумя точками на поверхности простым способом:

def distance(x1, y1, x2, y2):
    p1 = (x1, y1)
    p2 = (x2, y2)
    return math.hypot(p1[0] - p2[0], p1[1] - p2[1])

После начальной типизации тело функции будет содержать следующие операции:

v60 = malloc((GcStruct tuple2))
v61 = setfield(v60, ('item0'), x1_1)
v62 = setfield(v60, ('item1'), y1_1)
v63 = malloc((GcStruct tuple2))
v64 = setfield(v63, ('item0'), x2_1)
v65 = setfield(v63, ('item1'), y2_1)
v66 = getfield(v60, ('item0'))
v67 = getfield(v63, ('item0'))
v68 = int_sub(v66, v67)
v69 = getfield(v60, ('item1'))
v70 = getfield(v63, ('item1'))
v71 = int_sub(v69, v70)
v72 = cast_int_to_float(v68)
v73 = cast_int_to_float(v71)
v74 = direct_call(math_hypot, v72, v73)

Этот код не является оптимальным по нескольким причинам. В функции резервируется память для хранения двух кортежей, которые никогда не покинут ее пределы. К тому же, без видимых причин используется косвенный способ доступа к полям кортежей.

После выполнения оптимизации, направленной на удаление операций резервирования памяти, будет получен следующий сжатый код:

v53 = int_sub(x1_0, x2_0)
v56 = int_sub(y1_0, y2_0)
v57 = cast_int_to_float(v53)
v58 = cast_int_to_float(v56)
v59 = direct_call(math_hypot, v57, v58)

Операции резервирования памяти для хранения кортежей были полностью исключены, а также были удалены операции косвенного доступа. Чуть позже мы увидим то, как аналогичная удалению операций резервирования памяти техника используется на уровне приложения Python для реализации JIT-компиляции в PyPy (Раздел 19.5).

PyPy также занимается созданием inline-функций. Как и в языках более низкого уровня, применение inline-функций позволяет улучшить производительность RPython. Как это не удивительно, использование таких функций также позволяет уменьшить размер итогового бинарного файла. Это происходит из-за того, что появляется возможность выполнения большего количества оптимизаций сворачивания переменных и удаления операций резервирования памяти, благодаря которым и уменьшается общий объем кода.

Программа, представленная на данный момент в форме оптимизированных низкоуровневых потоковых графов, передается системе генерации кода для непосредственной генерации исходного кода. Перед тем, как она сможет сгенерировать исходный код на языке C, с помощью специфической системы генерации кода для языка C должны быть осуществлены некоторые дополнительные преобразования. Одним из таких преобразований является преобразование исключений, при осуществлении которого код для обработки исключений преобразуется для использования техники неавтоматизированной раскрутки стека. Другой оптимизацией является вставка проверок глубины стека. С помощью этих проверок могут быть сгенерированы исключения в процессе выполнения программы в том случае, если выполняется слишком глубокая рекурсия. Места, в которых требуются проверки глубины стека, определяются путем подсчета циклов с использованием графа вызовов программы.

Другим преобразованием, выполняемым системой генерации кода для языка C, является добавление функций для сборки мусора (garbage collection - GC). RPython, как и Python, является языком, использующим механизм сборки мусора, при этом язык C не является таковым, поэтому реализация механизма сборки мусора должна быть добавлена. Для этого система преобразования кода, предназначенная для реализации механизма сборки мусора, преобразует потоковые графы программы в потоковые графы с возможностью сборки мусора. Системы преобразования кода сборки мусора из состава PyPy демонстрируют то, как в ходе преобразования можно абстрагироваться от незначительных деталей. В CPython, где используется подсчет ссылок на ресурсы, в рамках кода интерпретатора на языке C должно осуществляться тщательное отслеживание ссылок на объекты Python, с которыми производятся манипуляции. В ходе этого процесса в рамках всей кодовой базы осуществляется реализация схемы сборки мусора, но эта схема подвержена воздействию незначительных ошибок, которые может допускать человек. Система преобразования кода, предназначенная для реализации механизма сборки мусора в рамках PyPy решает обе эти проблемы; она позволяет бесшовно подключать и отключать различные схемы сборки мусора. Не так сложно использовать реализацию системы сборки мусора (одну из многих предоставляемых в рамках PyPy), просто изменив параметр конфигурации во время преобразования. Говоря о ошибках системы преобразования, следует упомянуть о том, что система преобразования кода, предназначенная для реализации механизма сборки мусора, также никогда не совершает ошибок в определении ссылок на ресурс и не забывает о необходимости информирования системы сборки мусора в момент, когда объект перестает использоваться. Мощь абстракции для сборки мусора заключается в том, что эта абстракция позволяет использовать реализации систем сборки мусора, которые практически невозможно реализовать вручную в рамках интерпретатора. Например, некоторые реализации систем сборки мусора из состава PyPy требуют наличия барьера записи (write barrier). Барьер записи является проверкой, которая должна производиться каждый раз, когда контролируемый системой сборки мусора объект помещается в другой контролируемый системой сборки мусора массив или структуру. Процесс установления барьеров записи является трудоемким и может привести к ошибкам в случае ручной реализации, но он достаточно прост в том случае, когда выполняется автоматически системой преобразования кода, предназначенной для реализации механизма сборки мусора.

Наконец, система генерации кода получает возможность создания кода на языке C. Сгенерированный на основе низкоуровневых потоковых графов, код на языке C является уродливым нагромождением операторов goto и неочевидно названных переменных. Преимущество подхода, основанного на создании кода на языке C, заключается в том, что компилятор языка C может выполнить большую часть работы, заключающейся в сложных статических преобразованиях и требующейся для выполнения финальных оптимизаций циклов и резервирования регистров.

19.5. JIT-компиляция в PyPy

Python, как и большинство динамических языков программирования, традиционно отдает предпочтение гибкости в обмен на снижение производительности. Архитектура PyPy, обладая особенной гибкостью и широким спектром абстракций, затрудняет реализацию возможности очень быстрой интерпретации. Мощные абстракции пространств объектов и мультиметодов в стандартном пространстве объектов не могут быть реализованы без последствий. В результате производительность не модифицированного интерпретатора PyPy будет в четыре раза ниже производительности интерпретатора CPython. Для того, чтобы не создавать репутацию медленного языка не только для нашей реализации, но и для языка Python в общем, в рамках PyPy был реализован динамический компилятор (just-in-time compiler, обычно обозначаемый с помощью аббревиатуры JIT). С помощью JIT-компилятора часто используемые пути исполнения кода преобразуются в ассемблерное представление в процессе исполнения программы.

JIT-компилятор из состава PyPy использует преимущества уникальной архитектуры процесса преобразования кода в PyPy, описанной в Разделе 19.4. На самом деле PyPy не использует Python-специфичный JIT-компилятор; вместо него используется JIT-генератор. Генерация JIT-кода реализована просто в виде еще одной дополнительной фазы преобразования кода. Интерпретатор, желающий провести генерацию JIT-кода, должен осуществить два вызова специальных функций, называемых указаниями jit (jit hints).

JIT-генератор из состава PyPy является трассирующим JIT-генератором (tracing JIT). Это значит, что он определяет "горячие" (подразумевается часто используемые) циклы с целью их оптимизации путем компиляции в ассемблерный код. В момент, когда JIT-генератор принимает решение приступить к компиляции кода цикла, он записывает операции в рамках одной итерации цикла и этот процесс называется трассировкой (tracing). Эти операции впоследствии компилируются в машинный код.

Как было сказано ранее, JIT-генератору требуется только два указания от интерпретатора для генерации JIT-кода: merge_point и can_enter_jit. Функция can_enter_jit указывает JIT-генератору на начало цикла в рамках интерпретатора. При использовании интерпретатора языка Python это конец байткода JUMP_ABSOLUTE. (JUMP_ABSOLUTE заставляет интерпретатор перейти к началу цикла уровня приложения). Функция merge_point сообщает JIT-генератору о том, где он может безопасно вернуть управление интерпретатору. Это начало управляющего байткода цикла в интерпретаторе Python.

JIT-генератор вызывается после завершения фазы типизации RTyping в процессе преобразования кода. Повторим, что на данном этапе потоковые графы программы состоят из низкоуровневых операций и практически готовы к участию в процессе генерации целевого кода. JIT-генератор обнаруживает описанные ранее указания от интерпретатора и заменяет их на вызовы, предназначенные для задействования скомпилированного JIT-кода в процессе работы приложения. После этого JIT-генератор записывает сериализованное представление потоковых графов для каждой функции, в рамках которой интерпретатор желает произвести JIT-оптимизацию. Эти сериализованные потоковые графы называются jit-кодами (jitcodes). Все функции интерпретатора в этот момент описываются с помощью низкоуровневых операций RPython. Jit-коды сохраняются в финальном бинарном файле для использования в процессе работы приложения.

В процессе работы приложения на уровне JIT-генератора поддерживается счетчик для каждого цикла, исполняемого в ходе работы программы. В момент, когда счетчик цикла преодолевает заданное в процессе конфигурации пороговое значение, осуществляется вызов JIT-генератора и начинается трассировка. Ключевым для процесса трассировки объектом является мета-интерпретатор (meta-interpreter). Мета-интерпретатор исполняет jit-коды, сформированные в процессе преобразования кода. Таким образом, происходит их интерпретация средствами основного интерпретатора, отсюда и название компонента. По мере трассировки цикла, он создает список выполняемых операций и записывает их в промежуточном представлении JIT (JIT intermediate representation - JIT IR), являющемся другим форматом записи операций. Этот список называется трассировкой цикла (trace of the loop). В моменты, когда мета-интерпретатор сталкивается с вызовом функции, преобразованной с использованием JIT-компиляции (функции, для которой существует jit-код), мета-интерпретатор входит в нее и записывает операции в оригинальную трассировку. Таким образом, процесс трассировки оказывает эффект, заключающийся в уменьшении глубины стека вызовов; единственным типом вызовов в рамках трассировки являются вызовы функций интерпретатора, которые выходят за пределы сферы действия jit-кода.

Мета-интерпретатор вынужден преобразовывать данные трассировки в свойства итерации цикла, трассировка которого производится. Например, в момент, когда мета-интерпретатор сталкивается с условным переходом в jit-коде, он, как и ожидается, должен выбрать один путь исполнения кода на основе состояния программы. При осуществлении выбора на основе информации, полученной во время исполнения программы, мета-интерпретатор записывает операцию в промежуточном представлении, называемую охранной операцией (guard). В случае условного перехода это будет операция guard_true или guard_false в отношении переменной условия. В большинстве арифметических операций также используются охранные операции, которые позволяют быть уверенным в том, что в ходе выполнения арифметической операции не произойдет переполнения. По существу охранные операции позволяют объявлять в коде предположения, которые делает мета-интерпретатор в ходе трассировки. В момент генерации ассемблерного кода охранные операции будут защищать ассемблерный код от выполнения в контексте, для работы в котором он не предназначен. Трассировка заканчивается в тот момент, когда мета-интерпретатор достигает той же операции can_enter_jit, с которой и началась трассировка. Теперь код промежуточного представления цикла может быть передан оптимизатору.

JIT-оптимизатор выполняет выполнить несколько классических оптимизаций компиляторов и множество оптимизаций, специфичных для динамических языков программирования. Наиболее важными оптимизациями, относящимися к последней категории, являются оптимизации виртуальных (virtuals) и виртуализируемых (virtualizables) объектов.

Виртуальные объекты являются объектами, о которых известно, то, что они не покидают пространство трассировки, что подразумевает тот факт, что они не передаются в качестве аргументов при вызове внешних функций, не подвергающихся преобразованию в jit-код. Структуры и массивы постоянной длины также могут быть виртуальными объектами. Для виртуальных объектов не должна резервироваться память и их данные могут храниться непосредственно в регистрах и в стеке. (Это очень напоминает случай удаления статических операций резервирования памяти, описанный в разделе об оптимизациях преобразованного кода). Оптимизация виртуальных объектов позволяет удалить неэффективные операции косвенной адресации и резервирования памяти в рамках интерпретатора Python. Например, после преобразования в виртуальные объекты объектов Python для хранения в контейнерах целочисленных значений, эти значения могут быть извлечены из контейнеров и преобразованы в простые целочисленные значения длиной в машинное слово, после чего сохранены непосредственно в машинных регистрах.

Виртуализаруемые объекты ведут себя в значительной степени аналогично виртуальным объектам, но могут покидать пространство трассировки (т.е., передаваться функциям, не преобразованным в jit-код). Объект фрейма интерпретатора Python, который содержит значения переменных и указатель инструкций, помечен как виртуализируемый. Это позволяет оптимизировать манипуляции со стеком и другие операции, выполняемые в рамках фрейма. Несмотря на то, что виртуальные и виртуализируемые объекты похожи, в плане реализации у них нет ничего общего. Виртуализируемые объекты обрабатываются в момент осуществления трассировки мета-интерпретатором. Это отличает их от виртуальных объектов, которые обрабатываются в процессе оптимизации. Причиной реализации такого подхода является то, что виртуализируемые объекты требуют особого обращения, так как они могут покидать пространство трассировки. В частности, мета-интерпретатор должен убедиться в том, что не преобразованные в jit-код функции, которые могут использовать виртуализируемые объекты, на самом деле не будут пытаться получить прямой доступ к данным их полей. Это требование выдвигается из-за того, что в jit-коде данные полей виртуализируемых объектов хранятся в стеке и регистрах, поэтому данные реального виртуализированного объекта могут устареть в сравнении с текущими значениями, используемыми в рамках jit-кода. В процессе генерации jit-кода, код, получающий доступ к виртуализируемому объекту, модифицируется для проверки того, исполняется ли ассемблерный код, полученный в результате jit-компиляции. В том случае, если он исполняется, в рамках jit-кода осуществляется запрос обновления полей объекта на основе данных, полученных при выполнении ассемблерного кода. Дополнительно в тот момент, когда происходит возврат управления из внешней функции в jit-код, управление передается интерпретатору.

После проведения оптимизации данные трассировки готовы для преобразования в ассемблерный код. Так как промежуточно представление кода JIT само по себе является достаточно низкоуровневым, генерация ассемблерного кода не является очень сложной задачей. Большинство операций из промежуточного представления соответствуют всего лишь нескольким ассемблерным операциям для архитектуры x86. Система резервирования регистров использует простой линейный алгоритм. На данный момент увеличение затрат времени для использования более оптимизированного алгоритма резервирования регистров в обмен на генерацию немного более качественного кода не оправдывает себя. Наиболее сложными аспектами генерации ассемблерного кода являются интеграция сборщика мусора и реализация механизма восстановления состояния охранных операций. Сборщик мусора должен отслеживать корневые элементы стека в рамках генерируемого jit-кода. Эта возможность реализуется путем специальной поддержки динамических карт корневых элементов стека в рамках сборщика мусора.

При неудачном выполнении охранной операции скомпилированный ассемблерный код более не считается корректным и управление должно быть передано интерпретатору байткода. Эта операция передачи управления является одной их наиболее сложных частей реализации механизма JIT-компиляции, так как данные состояния интерпретатора должны быть воссозданы на основе данных состояния регистров и стека в момент неудачного завершения охранной операции. Для каждой охранной операции в рамках ассемблерного кода создается компактное описание того, где находятся все значения для воссоздания данных состояния интерпретатора. В случае неудачного выполнения охранной операции начинается выполнение функции, которая декодирует это описание и передает данные для восстановления на более высокий уровень для осуществления операции восстановления данных состояния. Неудачно выполненная охранная операция может находиться по середине пути исполнения запутанного кода операции, поэтому интерпретатор не сможет просто начать исполнение следующего кода операции. Для решения этой проблемы PyPy использует интерпретатор "чреной дыры" (blackhole interpreter). Интерпретатор "черной дыры" выполняет операции в рамках jit-кода с позиции неудачного выполнения охранной операции до достижения следующей точки безопасной передачи управления. Он не записывает данные о любых операциях, которые выполняет. Процесс некорректного завершения охранной операции проиллюстрирован на Рисунке 19.3.

Передача управления интерпретатору при неудачном выполнении охранной операции
Рисунок 19.3: Передача управления интерпретатору при неудачном выполнении охранной операции

Как было описано до этого момента, JIT-компиляция будет по существу бесполезной для любого цикла с часто изменяющимися условиями, так как неудачное выполнение охранной операции предотвратит выполнение ассемблерного кода для большого количества итераций. Каждая охранная операция поддерживает счетчик неудачных выполнений. После того, как количество неудачных выполнений преодолевает определенное пороговое значение, JIT-генератор начинает трассировку с той точки, где произошло неудачное завершение охранной операции вместо передачи управления интерпретатору. Эта дополнительная трассировка называется мостом (bridge). В момент, когда трассировка достигает завершения цикла, мост оптимизируется и компилируется, после чего оригинальный бинарный код цикла обновляется для того, чтобы после выполнения охранной операции управление переходило к новому мосту вместо кода после неудачного выполнения. Таким образом, циклы с динамическими условиями могут быть преобразованы в jit-код.

Насколько же хорошо зарекомендовали себя техники JIT-компиляции, применяемые в PyPy? В момент работы над этой главой среднее геометрическое для значений производительности реализаций PyPy и CPython указывает на пятикратное преимущество в быстродействии первой при использовании полного набора тестов производительности. При использовании JIT-компиляции код Python уровня приложений имеет возможность более быстрой работы, чем код уровня интерпретатора. Разработчики PyPy недавно столкнулись с замечательной задачей, заключающейся в необходимости реализации циклов уровня интерпретатора в рамках кода уровня приложения Python для достижения лучшей производительности.

Наиболее важным является то, что система JIT-компиляции не является специфичной для языка Python и это означает, что она может быть применена при разработке любого интерпретатора на основе фреймворка PyPy. Это не обязательно должен быть интерпретатор языка программирования. Например, JIT-компиляция используется для работы механизма регулярных выражений языка Python. NumPy является мощным модулем для работы с массивами языка Python, используемым при выполнении численных операций и научных исследований. В PyPy имеется экспериментальная повторная реализация модуля NumPy. Она использует мощные механизмы JIT-компиляции PyPy для ускорения операций с массивами. Несмотря на то, что реализация модуля NumPy все еще находится на раннем этапе своего развития, начальные замеры производительности выглядят многообещающе.

19.6. Недостатки архитектуры

Хотя разработка приложений на языке RPython в любом случае проще, чем на языке C, опыт такой разработки может привести к разочарованию. В первое время сложно использовать применяющуюся в нем явную типизацию. Не все возможности языка программирования Python поддерживаются, а на поддерживаемые возможности накладываются произвольные ограничения. Язык RPython не имеет формальной спецификации и все принимаемые системой преобразования кода синтаксические конструкции могут меняться день ото дня из-за того, что язык RPython адаптируется для удовлетворения требований фреймворка PyPy. Автору этой главы часто удается создавать программы, в ходе получасовой обработки которых с помощью системы преобразования кода выводится непонятное сообщение об ошибке и процесс преобразования прекращается.

Тот факт, что система преобразования кода RPython производит анализ всей программы, создает несколько практических проблем. Любое минимальное изменение преобразуемого кода приводит к необходимости повторного преобразования кода всего интерпретатора. На данный момент процесс преобразования кода растягивается на 40 минут при использовании быстрой современной системы. Эта задержка особенно раздражает тогда, когда тестируются изменения, затрагивающие систему JIT-компиляции, ведь для измерения производительности необходимо произвести преобразование кода интерпретатора. Требование наличия всего кода программы перед его преобразованием подразумевает то, что модули, содержащие код на языке RPython, не могут быть скомпилированы и загружены отдельно от основного кода интерпретатора.

Уровни абстракции в PyPy не всегда так четко разделены, как это выглядит в теории. Хотя технически JIT-генератор должен иметь возможность создания замечательного JIT-компилятора для языка с использованием только двух упомянутых ранее указаний, в реальности он работает лучше с одним определенным кодом, нежели с другим. Был проведен большой объем работы для того, чтобы интерпретатор Python был "лучше совместим с процессом генерации jit-кода", включая реализацию большего количества JIT-указаний и даже новые структуры данных, специально оптимизированные для работы с JIT-генератором.

Большое количество уровней абстракции PyPy может сделать поиск ошибок достаточно сложным процессом. Ошибка интерпретатора Python может находиться в самом коде интерпретатора, либо скрываться где-либо в семантиках языка RPython и инструментарии для преобразования кода. Отладка особенно осложняется в том случае, когда ошибка не может быть воспроизведена без преобразования кода интерпретатора. Обычно в этих случаях прибегают к использованию отладчика GDB по отношению к приложению, скомпилированному на основе практически нечитаемого автоматически сгенерированного исходного кода на языке C.

Преобразование даже ограниченного подмножества языка Python в такой более низкоуровневый язык, как C, не является простой задачей. Фазы процесса преобразования, описанные в Разделе 19.4, на самом деле не являются независимыми. В процессе преобразования кода производится создание аннотаций и типизация функций, при этом система создания аннотаций располагает информацией о низкоуровневых типах. Следовательно, система преобразования кода для языка RPython сталкивается с запутанной схемой зависимостей. Система преобразования может немного упростить ее в некоторых местах, но этот процесс не прост и не доставляет удовольствия.

19.7. Немного о процессе разработки

В ходе борьбы со сложностью реализации (обратитесь к Разделу 19.6) в рамках фреймворка PyPy начали применяться несколько так называемых "гибких" методологий разработки. Наиболее важной из них является разработка через тестирование. Все новые возможности и исправления ошибок должны сопровождаться тестами, позволяющими установить корректность их реализации. Интерпретатор языка Python из состава фреймворка PyPy также проходит тестирование с использованием набора тестов для обнаружения регрессий проекта CPython. Система тестирования фреймворка PyPy py.test была выделена из состава проекта и на данный момент используется многими другими проектами. При разработке PyPy также применяется система непрерывной интеграции, с помощью которой выполняется набор тестов и осуществляется перенос проекта на множество платформ. Бинарные файлы для всех платформ создаются ежедневно, после чего они подвергаются тестированию с помощью набора тестов. Все эти тесты позволяют быть уверенным в том, что различные компоненты функционируют в штатном режиме вне зависимости от того, какие изменения сложной архитектуры проекта были осуществлены.

Существует строгая культура проведения экспериментов в рамках проекта PyPy. Разработчики должны создавать ветви кода в репозитории Mercurial. В рамках этих ветвей могут быть реализованы связанные с разработкой проекта идеи без дестабилизации основной ветви кода. Идеи не всегда успешно реализуются в рамках этих ветвей исходного кода, поэтому некоторые ветви остаются в заброшенном состоянии. Во всяком случае, разработчики проекта PyPy очень настойчивы. Наиболее известным доказательством этого утверждения является тот факт, что современная система JIT-компиляции PyPy появилась в ходе пятой попытки добавления функций JIT-компиляции в фреймворк PyPy!

Проект PyPy также известен своими инструментами визуализации. Визуализации графов потоков данных из Раздела 19.4 являются одним из примеров применения этих инструментов. В составе проекта PyPy есть также инструменты для демонстрации вызовов сборщика мусора с течением времени и обзора деревьев разбора регулярных выражений. Особенно интересным инструментом является jitviewer - программа, которая позволяет визуализировать уровни преобразованной в jit-код функции при преобразовании из байткода Python в промежуточное представление JIT с последующим преобразованием в ассемблерный код. (Вывод программы jitveiwer показан на Рисунке 19.4.) Инструменты визуализации помогают разработчикам понимать принципы взаимодействия множества уровней абстракции фреймворка PyPy.

Программа jitveiwer выводит байткод Python и соответствующие операции промежуточного представления JIT
Рисунок 19.4: Программа jitveiwer выводит байткод Python и соответствующие операции промежуточного представления JIT

19.8. Резюме

Интерпретатор языка Python рассматривает объекты как черные ящики и позволяет полностью описывать их поведение в рамках пространства объектов. Отдельные пространства объектов могут позволять объектам языка Python расширять свои возможности. Подход, заключающийся в использовании пространства объектов, позволяет использовать технику абстрактной интерпретации в процессе преобразования кода.

Система преобразования кода RPython позволяет реализовывать такие возможности, как сборка мусора и обработка исключений, абстрагированные от интерпретатора языка программирования. Она также делает возможным использование фреймворка PyPy на множестве различных платформ при использовании различных систем генерации кода.

Одним из наиболее важных методов применения архитектуры преобразования кода является возможность использования JIT-генератора. Обобщенная архитектура JIT-генератора позволяет использовать возможности JIT-компиляции для новых языков программирования и таких подвидов языков, как регулярные выражения. PyPy является быстрейшей реализацией языка программирования Python благодаря использованию встроенного JIT-генератора.

Хотя большая часть усилий в ходе разработки фреймворка PyPy направлена на развитие интерпретатора языка Python, фреймворк PyPy может использоваться для реализации интерпретатора любого динамического языка программирования. В течение многих лет на основе фреймворка PyPy создавались незаконченные интерпретаторы для языков JavaScript, Prolog, Scheme и IO.

19.9. Выученные уроки

Наконец, перечислим несколько уроков, извлеченных из процесса разработки проекта PyPy:

Повторяемый рефакторинг кода является обычно необходимым процессом. Например, изначально было выдвинуто предложение обработки высокоуровневых потоковых графов с помощью системы генерации кода на языке C! Прошло несколько рефакторингов кода до того момента, как был реализован текущий многофазный процесс преобразования кода.

Наиболее важным уроком, извлеченным из процесса разработки PyPy, является понимание мощности абстракции. В PyPy абстракции служат для разделения областей реализаций. Например, возможность автоматической сборки мусора языка RPython позволяет разработчику, работающему с интерпретатором, не беспокоиться об управлении памятью. В то же время, абстракции имеют подсознательную значимость. Работа с цепочкой преобразования кода подразумевает жонглирование различными фазами преобразования на уровне воображения. Поиск уровня абстракции, на котором допущена ошибка может быть также затруднен из-за применения абстракций; нарушение абстракций, когда происходит подмена низкоуровневых компонентов, которые должны быть взаимозаменяемыми приводит к нарушению работы высокоуровневого кода, что является извечной проблемой. Важно использовать тесты для проверки того, что все части системы работоспособны, поэтому изменение в одной системе не приведет к неработоспособности другой. Говоря более конкретно, абстракции могут замедлить программу, создавая большое количество косвенных преобразований.

Гибкость языка (R)Python, используемого в качестве языка реализации, позволяет проводить эксперименты с новыми возможностями языка Python (или даже с новыми языками) достаточно просто. Из-за своей уникальной архитектуры проект PyPy будет играть важную роль в будущей реализации языка Python и других динамических языков программирования.

20.1. Сложность создания слоя абстракции для баз данных

При использовании термина "слой абстракции для баз данных" принято считать, что имеется в виду система взаимодействия с базой данных, которая скрывает большую часть подробностей о том, как данные хранятся и извлекаются. Этот термин иногда трактуется более радикально и в этом случае считается, что система должна скрывать не только специфику используемой реляционной базы данных, но даже и подробности формирования самих реляционных структур, а также то, является или не является используемое хранилище данных реляционным.

Наиболее часто критики инструментов для объектно-реляционного отображения основывают свои утверждения на предположении о том, что главной целью подобного инструмента является "сокрытие" факта использования реляционной базы данных, выполнение задачи по конструированию запросов и взаимодействию с базой данных и сокрытие множества подробностей реализации этого взаимодействия. Главной характерной чертой данного сокрытия является возможность перевода операций создания и осуществления запросов реляционных структур из ведения разработчика в ведение прозрачно работающей библиотеки.

Те, кто имеет богатый опыт работы с реляционными базами данных, знают о том, что этот подход является абсолютно не практичным. Реляционные структуры и SQL-запросы являются очень функциональными и входят в состав основных архитектурных элементов приложений. То, как эти структуры должны проектироваться, организовываться и обрабатываться при работе с запросами, зависит не только от желаемых данных, но также и от структуры информации. В том случае, если эти возможности будут скрываться, не будет большого смысла в использовании в первую очередь реляционной базы данных.

Проблема согласования приложений, которые пытаются скрывать реализацию используемой реляционной базы данных с фактом, заключающимся в том, что реляционные базы данных требуют специфического подхода, обычно называется "проблемой объектно-реляционного несоответствия". SQLAlchemy предлагает сравнительно новый подход к решению данной проблемы.

Подход к созданию слоя абстракции для баз данных, используемый в рамках SQLAlchemy

SQLAlchemy предполагает, что разработчик пожелает использовать реляционную форму для своих данных. Система, которая изначально устанавливает и скрывает схему и принятые для формирования запросов архитектурные решения, ухудшает свои пользовательские качества при работе с реляционными базами данных, что ведет к появлению всех классических проблем несоответствия.

В то же время, реализация этих решений может и должна быть произведена в соответствии с высокоуровневыми шаблонами проектирования настолько, насколько это возможно. Установление связи модели объектов со схемой и реализация этой связи с помощью запросов является скучным занятием. Использование инструментов для автоматизации этих задач позволяет сделать процесс разработки приложения более коротким, понятным и эффективным, а также позволяет завершить его в течение того же промежутка времени, который мог потребоваться для разработки позволяющего выполнить эту задачу кода вручную.

В этом случае SQLAlchemy рассматривается как тулкит, который позволяет повысить значение роли разработчика с пассивного пользователя решений, предлагаемых библиотекой, до архитектора/создателя реляционных структур и связей между этими структурами и приложением. Раскрывая реляционные концепции, SQLAlchemy использует идею "неполной абстракции", принуждая разработчика к созданию специального и в то же время полностью автоматизированного уровня взаимодействия между приложением и реляционной базой данных. Инновация SQLAlchemy заключается в степени, до которой эта система позволяет осуществлять автоматизацию, причем данная автоматизация не снижает степень контроля разработчика над реляционной базой данных.

20.2. Дихотомия между основными задачами и объектно-реляционным отображением

Основной задачей, поставленной при разработке SQLAlchemy, было предоставление тулкита, который раскрывал бы каждый уровень взаимодействия с базой данных в рамках богатого набора функций API, разделяя задачи на две основных категории, известные как основные (Core) и связанные с объектно-реляционным отображением (ORM). Основные задачи заключаются во взаимодействии с API для работы с базами данных языка Python (DBAPI), выводе текстовых выражений SQL, понятных базе данных, а также управлении схемами. Все эти возможности доступны через публичные API. ORM, или объектно-реляционное отображение реализуется в рамках специфической библиотеки, созданной для работы с основными функциями системы. Объектно-реляционное отображение, реализуемое в рамках SQLAlchemy, является одним из неограниченного количества возможных уровней абстракции объектов, которые могут быть реализованы с использованием основных функций, причем многие разработчики и организации создают свои приложения, непосредственно использующие основные функции системы.

Диаграмма уровней SQLAlchemy
Рисунок 20.1: Диаграмма уровней SQLAlchemy

Разделение на основную часть и объектно-реляционное отображение всегда было наиболее характерной чертой SQLAlchemy, при этом данная черта имеет как достоинства, так и недостатки. Явно реализованная основная часть SQLAlchemy предполагает установление связи между атрибутами класса объектно-реляционного отображения и структурой, известной как Table, вместо непосредственной связи с названиями столбцов в строковом формате, как это установлено в базе данных; осуществление запроса SELECT с использованием структуры с именем select вместо объединения атрибутов объектов непосредственно с выражениями в строковой форме; а также прием результирующих строк с использованием фасадного класса с именем ResultProxy, который прозрачно отображает структуру select на каждую из результирующих срок вместо передачи данных непосредственно от курсора из базы данных в заданный пользователем объект.

Элементы основной части могут быть незаметны в очень простых приложениях, использующих объектно-реляционное отображение. Однако, из-за того, что основные функции тщательно интегрированы в код объектно-реляционного отображения для предоставления возможности плавного перехода между конструкциями объектно-реляционного отражения и основной системы, более сложное приложение на основе объектно-реляционного отображения может "перейти ниже" на один или несколько уровней для того, чтобы осуществлять взаимодействие с базой данных более специфическим и оптимизированным способом в зависимости от ситуации. По мере развития проекта SQLAlchemy API основной части становился менее пригодным для регулярного использования, в то время, как объектно-реляционное отображение продолжает предоставлять более изощренные и обобщенные шаблоны. Тем не менее, доступность основной части также внесла свою лепту в ранний успех системы SQLAlchemy, так как это обстоятельство позволило пользователям ранних версий достичь гораздо лучших результатов, чем те, которые могли быть достигнуты при использовании разрабатываемой системы объектно-реляционного отображения.

Недостатком подхода, заключающегося в разделении системы на основную часть и объектно-реляционное отображение, является большее количество шагов, требующихся для доставки инструкций. Стандартная реализация интерпретатора языка программирования Python, созданная с использованием языка программирования C, известна значительными затратами ресурсов на осуществление отдельных вызовов функций, которые являются основной причиной снижения производительности в процессе работы приложения. Традиционные методы обхода этой проблемы заключаются в сокращении цепочек вызовов функций путем перераспределения кода и включения кода в состав существующих функций, а также в замене требующих высокой производительности фрагментов кода на код на языке C. Разработчики SQLAlchemy потратили много лет на использование двух описанных выше методов с целью повышения производительности системы. Однако, продолжающееся распространение интерпретатора PyPy для языка Python может решить оставшиеся проблемы с производительностью без необходимости переписывания большей части внутреннего кода SQLAlchemy на языке C, так как PyPy значительно сокращает потерю производительности при использовании длинных цепочек вызовов функций, применяя inline-функции в ходе процесса JIT-компиляции.

20.3. Использование DBAPI

В основании SQLAlchemy находится подсистема для взаимодействия с базами данных посредством DBAPI. Сам по себе DBAPI представлен не отдельной библиотекой, а исключительно спецификацией. Поэтому реализации спецификации DBAPI доступны для определенных целевых баз данных, таких, как MySQL или PostgreSQL или в качестве альтернативы для определенных адаптеров для баз данных, не совместимых с DBAPI, таких, как ODBC и JDBC.

Использование DBAPI приводит к двум сложностям. Первая сложность заключается в необходимости предоставления простого и полнофункционального фасадного класса для рудиментарных шаблонов использования DBAPI. Вторая сложность заключается в необходимости обработки значительных отличий специфических реализаций DBAPI, а также используемых систем баз данных.

Система диалектов

Интерфейс, реализуемый в рамках DBAPI является чрезвычайно простым. Его ключевыми компонентами являются сам модуль DBAPI, объект соединения и объект курсора - курсор базы данных представляет контекст определенного запроса и ассоциированные с ним результаты. Простое взаимодействие с этими объектами, направленное на установление соединения с базой данных и извлечение данных из нее, может быть реализовано следующим образом:

connection = dbapi.connect(user="user", pw="pw", host="host")
cursor = connection.cursor()
cursor.execute("select * from user_table where name=?", ("jack",))
print "Результирующие столбцы:", [desc[0] for desc in cursor.description]
for row in cursor.fetchall():
    print "Строка:", row
cursor.close()
connection.close()

В рамках SQLAlchemy реализован фасадный класс для классического взаимодействия с DBAPI. Точкой входа этого фасадного класса является вызов create_engine, с помощью которого устанавливается соединение и собирается конфигурационная информация. В качестве результата выполнения вызова возвращается экземпляр класса Engine. Этот объект представляет только способ осуществления запроса через DBAPI, причем последний никогда непосредственно не раскрывается.

Для простого выполнения запросов объект Engine предоставляет интерфейс, известный под названием "интерфейс явного исполнения запросов" ("implicit execution interface"). Работа по созданию и закрытию соединения с базой данных и курсора посредством DBAPI выполняется незаметно для разработчика:

engine = create_engine("postgresql://user:pw@host/dbname")
result = engine.execute("select * from table")
print result.fetchall()

В версии SQLAlchemy 0.2 был впервые представлен объект Connection, позволяющий явно выполнять этапы процесса соединения с базой данных посредством DBAPI:

conn = engine.connect()
result = conn.execute("select * from table")
print result.fetchall()
conn.close()

Возвращаемый методом execute класса Engine или Connection результат называется ResultProxy и предоставляет интерфейс, аналогичный интерфейсу курсора в DBAPI, но с большим набором функций. Объекты Engine, Connection и ResultProxy связаны с модулем DBAPI и являются экземплярами определенного соединения DBAPI и определенного курсора DBAPI соответственно.

На заднем плане объект Engine ссылается на объект, называемый Dialect. Dialect является абстрактным классом, для которого существует множество реализаций, причем каждая из этих реализаций предназначена для работы с определенной комбинацией DBAPI и базы данных. Объект Connection, создаваемый на стороне объекта Engine, будет ссылаться на этот объект Dialect при принятии всех решений, которые могут варьироваться в зависимости от используемых DBAPI и базы данных.

После создания объект Connection будет создавать и поддерживать рабочее соединение DBAPI из репозитория, известного, как Pool, который также ассоциирован с объектом Engine. Репозиторий Pool ответственен за создание новых соединений DBAPI и обычно за сохранение их в расположенном в памяти пуле для периодического использования.

В процессе исполнения запроса объектом Connection создается дополнительный объект с именем ExecutionContext. Этот объект существует с момента исполнения запроса в течение периода существования объекта ResultProxy. Он также может быть доступен как специфический подкласс для некоторых комбинаций DBAPI и баз данных.

Рисунок 20.2 иллюстрирует все эти объекты и их взаимоотношения с другими объектами, а также с компонентами DBAPI.

API объектов Engine, Connection, ResultProxy
Рисунок 20.2: API объектов Engine, Connection, ResultProxy

Обработка различий интерфейсов DBAPI

Перед рассмотрением задачи обработки различий интерфейсов DBAPI, давайте для начала рассмотрим суть существующей проблемы. Спецификация DBAPI, на данный момент второй версии, написана в форме наборов объявлений API, которые позволяют реализовывать значительно отличающиеся по поведению интерфейсы, а также оставляют большое количество недокументированных областей. В результате существующие реализации DBAPI демонстрируют значительные отличия в некоторых областях, включая возможность или невозможность передачи строк языка Python в кодировке Unicode; способ получения "последнего добавленного идентификатора", являющегося автоматически генерируемым первичным ключом, после выполнения запроса INSERT; а также способ указания и интерпретации граничных значений. Эти интерфейсы также ведут себя индивидуально в зависимости от используемых типов данных, в ситуациях, когда производится обработка бинарных данных, точных числовых данных, дат, логических данных, а также строк в кодировке Unicode.

SQLAlchemy решает эту проблему, допуская различия в классах Dialect и ExecutionContext путем использования множества уровней подклассов. Рисунок 20.3 иллюстрирует отношение между объектами Dialect и ExecutionContext в случае использования диалекта psycopg2. Класс PGDialect реализует специфичные для базы данных PostgreSQL возможности, такие, как поддержка типа данных ARRAY и каталогов схем; класс PGDialect_psycopg2 реализует возможности, специфичные для реализации DBAPI psycopg2, включающие обработчики строк в кодировке Unicode и поддержку управления курсором на стороне сервера базы данных.

Простая иерархия классов Dialect/ExecutionContext
Рисунок 20.3: Простая иерархия классов Dialect/ExecutionContext

Вариант описанного выше шаблона проектирования оправдывает себя при работе с реализацией DBAPI, поддерживающей множество баз данных. Примерами таких реализаций являются pyodbc, которая может взаимодействовать с неограниченным количеством баз данных посредством ODBC и zxjdbc, предназначенная только для использования совместно с языком Jython и работающая с JDBC. Описанное выше отношение классов реализуется путем использования смешанного класса из пакета sqlalchemy.connectors, который реализует особенности работы интерфейса DBAPI, свойственные для множества баз данных. Рисунок 20.4 иллюстрирует стандартные функции класса sqlalchemy.connectors.pyodbc, разделяемые между pyodbc-специфичными диалектами для баз данных MySQL и Microsoft SQL Server.

Стандартные функции DBAPI, разделяемые между иерархиями диалектов
Рисунок 20.4: Стандартные функции DBAPI, разделяемые между иерархиями диалектов

Объекты Dialect и ExecutionContext предоставляют возможность для описания каждого взаимодействия с базой данных и DBAPI, включая то, как должны форматироваться аргументы функции соединения, а также как должны обрабатываться специальные данные в процессе исполнения запроса. Объект Dialect также является фабрикой для компиляции синтаксических конструкций языка SQL, которая осуществляет корректное оформление SQL-запроса для целевой базы данных, а также преобразования типов объектов данных в соответствии с тем, как объекты данных языка Python должны упаковываться и распаковываться при использовании целевых интерфейса DBAPI и базы данных.

20.4. Описание схемы

После установления соединения с базой данных и получения возможности взаимодействия с ней приобретает актуальность задача создания и осуществления манипуляций с зависящими от используемых баз данных SQL-запросами. Для решения этой задачи нам потребуется сформулировать метод создания ссылок на таблицы и столбцы, присутствующие в базе данных - так называемую "схему". Таблицы и столбцы представляют метод организации данных и большинство SQL-запросов состоит из выражений и команд, ссылающихся на эти структуры.

Объектно-реляционное отображение или уровень доступа к данным должен предоставлять программный доступ к возможностям языка SQL; в их основе лежит программная система описания таблиц и столбцов. Это именно то место, где в рамках SQLAlchemy происходит первое жесткое разделение на основную систему и объектно-реляционное отображение путем реализации конструкций Table и Column, которые описывают структуру базы данных независимо от пользовательского описания класса модели. Обоснованием разделения описания схемы и объектно-реляционного отображения является тот факт, что реляционная схема может быть спроектирована исключительно с использованием терминологии реляционных баз данных, включая платформо-специфичные особенности в случае необходимости без осложнения этого процесса путем введения в него объектно-реляционных концепций - они будут использоваться отдельно. Независимость от компонента объектно-реляционного отображения также подразумевает то, что существующая система описания схем становится настолько же функциональной, как и любая другая объектно-реляционная система, которая могла бы быть реализована на базе основной системы.

Объекты моделей Table и Column реализуются в области так называемых метаданных (metadata), в которой объект коллекции с именем MetaData представляет коллекцию объектов Table. Структура объектов по большей части реализована в соответствии с описанием "отображения метаданных" ("Metadata Mapping") из книги Martin Flower под названием "Patterns of Enterprise Application Architecture". Рисунок 20.5 иллюстрирует некоторые ключевые элементы пакета sqlalchemy.schema.

Базовые объекты пакета sqlalchemy.schema
Рисунок 20.5: Базовые объекты пакета sqlalchemy.schema

Объект Table представляет имя и другие атрибуты текущей таблицы, присутствующей в целевой схеме. Его коллекция объектов Column представляет информацию об именах и типах для определенных столбцов таблицы. Заполненный массив объектов, описывающих ограничения, индексы и последовательности создается для предоставления большего объема информации о таблице, причем некоторые данные непосредственно влияют на принцип работы базы данных и системы формирования SQL-запросов. В частности, объект ForeignKeyConstraint является ключевым при определении метода объединения двух таблиц.

Объекты Table и Column уникальны по сравнению со всеми остальными объектами из пакета для работы со схемами, так как они используют двойное наследование от объектов из пакетов sqlalchemy.schema и sqlalchemy.sql.expression, работая не только как конструкции уровня обработки схем, но также и как синтаксические единицы языка для создания выражений SQL. Это отношение проиллюстрировано на Рисунке 20.6.

Двойная жизнь объектов Table и Column
Рисунок 20.6: Двойная жизнь объектов Table и Column

На Рисунке 20.6 мы можем видеть, что объекты Table и Column наследуются от объектов из мира SQL как специфические формы "вещей из которых вы можете выбрать", известных под именем FromClause и "вещей, которые вы можете использовать в SQL-запросе", известных под именем ColumnElement.

20.5. SQL-запросы

В момент начала разработки SQLAlchemy способ генерации SQL-запросов не был ясен. Текстовый язык мог быть хорошим кандидатом; это стандартный подход, лежащий в основе таких широко известных инструментов объектно-реляционного отображения, как HQL из состава Hibernate. В случае использования языка программирования Python, однако, был доступен более занимательный вариант: использование объектов и выражений языка Python для генерации древовидных структур представления запросов, причем возможным было даже изменение назначения операторов языка Python с целью использования их для формирования SQL-запросов.

Хотя рассматриваемый инструмент и не был первым инструментом, выполняющим подобные функции, следует упомянуть о библиотеке SQLBuilder из состава SQLObject от Ian Bicking, которая была использована как образец при создании системы работы с объектами языка Python и операторами, используемыми в рамках языка формирования запросов SQLAlchemy. При использовании данного подхода объекты языка Python представляют лексические части SQL-запроса. Методы этих объектов, также как и перегружаемые операторы, позволяют генерировать новые унаследованные от существующих лексические конструкции. Наиболее часто используемым объектом является представляющий столбец объект "Column" - библиотека SQLObject будет представлять такие объекты в рамках класса объектно-реляционного отображения, используя пространство имен с доступом посредством атрибута .q; также в SQLAlchemy объявлен атрибут с именем .c. Этот атрибут .c на сегодняшний день поддерживается и используется для представления элементов основной части, подвергающихся выборке, таких, как объекты, представляющие таблицы и запросы выборки.

Древовидные структуры запросов

Конструкция SQL-запроса в SQLAlchemy очень похожа на структуру, которую вы можете создать в случае разбора готового SQL-запроса - это дерево разбора синтаксических конструкций, отличающееся лишь тем, что вместо формирования на основе строки запроса его создает непосредственно разработчик. Основной тип ветви этого дерева разбора носит имя ClauseElement, а Рисунок 20.7 иллюстрирует отношение класса ClauseElement и некоторых ключевых классов.

Базовая иерархия классов запроса
Рисунок 20.7: Базовая иерархия классов запроса

Путем использования функций-конструкторов, методов и перегруженных функций операторов языка Python, структура аналогичного следующему запроса:

SELECT id FROM user WHERE name = ?

может быть следующим образом сформирована с помощью средств языка Python:

from sqlalchemy.sql import table, column, select
user = table('user', column('id'), column('name'))
stmt = select([user.c.id]).where(user.c.name=='ed')

Структура описанной выше конструкции select показана на Рисунке 20.8. Следует отметить, что представление строкового значения 'ed' находится внутри конструкции _BindParam, что приводит к трактовке его как маркера параметра ограничения, для обозначения которого в строке SQL-запроса используется знак вопроса.

Пример древовидного представления запроса
Рисунок 20.8: Пример древовидного представления запроса

При рассмотрении древовидной диаграммы можно увидеть, что в ходе простого обратного обхода узлов может быть быстро получен сформированный SQL-запрос, при этом мы сможем ознакомиться с упомянутым процессом гораздо подробнее в разделе, посвященном компиляции запросов.

Подход к использованию операторов языка Python

В SQLAlchemy подобный следующему запрос:

column('a') == 2

возвращает не логический результат True или False, а конструкцию SQL-запроса. Это делается для того, чтобы выполнить перегрузку операторов с помощью специальных функций для управления операторами языка Python, т.е., таких методов, как __eq__, __ne__, __le__, __lt__, __add__, __mul__. Узлы структуры, связанные со столбцами, предоставляют возможность работы с перегруженными операторами языка Python путем использования смешанного класса с именем ColumnOperators. После использования возможности перегрузки операторов запрос column('a') == 2 эквивалентен следующему коду:

from sqlalchemy.sql.expression import _BinaryExpression
from sqlalchemy.sql import column, bindparam
from sqlalchemy.operators import eq

_BinaryExpression(
    left=column('a'),
    right=bindparam('a', value=2, unique=True),
    operator=eq
)

Конструкция eq на самом деле является функцией из встроенного модуля operator. Представление операторов в виде объектов (т.е., operator.eq) вместо строк (т.е., =) позволяет задавать строковое представление запроса во время компиляции, когда имеется информация о диалекте используемой базы данных.

Компиляция

Главным классом, ответственным за преобразование древовидных представлений SQL-запросов в текстовый формат SQL-запросов является класс с именем Compiled. Этот класс имеет два основных подкласса, SQLCompiler и DOLCompiler. Класс SQLCompiler выполняет операции преобразования запросов SELECT, INSERT, UPDATE и DELETE, обобщенно классифицируемых как элементы языка запросов данных (data query language - DQL) и языка для манипуляций с данными (data manipulation language - DML), в то время, как класс DDLCompiler выполняет операции преобразования различных запросов CREATE и DROP, классифицируемых как элементы языка описания данных (data definition language - DDL). Существует дополнительная иерархия классов, предназначенная для реализации функций преобразования представлений строк на основе типов, задаваемых в рамках класса TypeCompiler. Отдельные диалекты предоставляют свои собственные подклассы всех трех типов классов компилятора с целью поддержки специфических для используемой базы данных аспектов языка SQL-запросов. На Рисунке 20.9 представлен обзор этой иерархии классов, сформированной для работы с диалектом PostgreSQL.

Иерархия классов компилятора, включающая специфичные для PostgreSQL реализации классов
Рисунок 20.9: Иерархия классов компилятора, включающая специфичные для PostgreSQL реализации классов

Подклассы класса Compiled описывают наборы visit-методов, на каждый из которых ссылается определенный подкласс класса ClauseEvent. Производится обход иерархии классов узлов ClauseEvent, после чего запрос формируется путем рекурсивного объединения строковых результатов, возвращаемых каждой из visit-функций. По мере выполнения этой работы объект Compiled изменяет состояние в зависимости от имен анонимных идентификаторов, имен граничных параметров, а также находящихся среди прочих параметров в составе запроса подзапросов, каждый из которых влияет как на результат генерации строки SQL-запроса, так и на конечный набор граничных параметров с их значениями по умолчанию. Рисунок 20.10 иллюстрирует процесс использования visit-методов для получения текстовых фрагментов запроса.

Иерархия вызовов функций в ходе компиляции запроса
Рисунок 20.10: Иерархия вызовов функций в ходе компиляции запроса

Заполненная данными структура Compiled содержит завершенную строку SQL-запроса и набор граничных значений. Эти данные преобразуются с помощью класса ExecutionContext в формат, ожидаемый методом execute реализации интерфейса DBAPI, который использует предположения об обязательном использовании кодировки Unicode в рамках объекта запроса, о типе коллекции, используемой для хранения граничных значений, а также о специфике того, как граничные условия должны преобразовываться в представления, подходящие для использования совместно с реализацией интерфейса DBAPI и используемой базой данных.

20.6. Отображение классов при использовании объектно-реляционного отображения

Переключим наше внимание на объектно-реляционное отображение. Первой целью является использование описанной нами системы таблиц метаданных для предоставления возможности переноса функций заданного пользователем класса на коллекцию столбцов в таблице базы данных. Второй целью является предоставление возможности описания отношений между заданными пользователем классами, которые будут основываться на отношениях между таблицами в базе данных.

В SQLAlchemy такая связь называется "отображением", что соответствует широко известному шаблону проектирования с названием "DataMapper", описанному в книге Martin Flower с названием "Patterns of Enterprise Architecture". В целом, система объектно-реляционного отображения SQLAlchemy была разработана с применением большого количества приемов, которые описал в своей книге Martin Flower. Она также подверглась значительному влиянию со стороны известной системы реляционного отображения Hibernate для языка программирования Java и продукта SQLObject для языка программирования Python от Ian Bicking.

Классическое отображение против декларативного отображения

Мы используем термин "классическое отображение" для указания на систему объектно-реляционного отображения данных для существующего пользовательского класса в рамках SQLAlchemy. Эта форма отображения использует объект Table и заданный пользователем класс для формирования двух связанных с помощью функции с именем mapper, но при этом отдельных, примитивов. Как только функция mapper применяется по отношению к заданному пользователем классу, класс приобретает новые атрибуты, соответствующие столбцам таблицы:

class User(object):
    pass

mapper(User, user_table)

# теперь у объекта User есть атрибут ".id"
User.id

Функция mapper также может добавлять другие атрибуты классу, включая атрибуты, соответствующие как ссылкам на другие типы объектов, так и на произвольные SQL-запросы. Процесс добавления произвольных атрибутов классу в мире Python известен под названием "monkeypatching"; однако, так как мы выполняем его на основе данных и не привольным образом, суть процесса гораздо лучше может быть обозначена термином "доработка класса" ("class instrumentation").

Современный подход к работе с SQLAlchemy базируется на использовании декларативного расширения, которое представляет собой систему конфигурации, имеющую сходство с известной, похожей на active record системой декларативного описания классов, используемой во множестве других инструментов объектно-реляционного отображения. В этой системе конечный пользователь явно задает описание атрибута в процессе создания описания класса, каждое из которых представляет атрибут класса, который должен быть использован при создании отображения. Объект Table в большинстве случаев не упоминается ни явно, ни при использовании функции mapper; явно упоминается только пользовательский класс, объекты Column и другие относящиеся к отображению атрибуты:

class User(Base):
    __tablename__ = 'user'
    id = Column(Integer, primary_key=True)

При рассмотрении приведенного выше фрагмента кода может показаться, что добавление атрибутов в класс осуществляется непосредственно в строке id = Column(), но на самом деле это не так. Декларативное расширение использует метакласс Python, с помощью которого очень удобно выполнять серии операций каждый раз, когда новый класс впервые декларируется с целью генерации нового объекта Table на основе декларации и передачи его функции mapper вместе с классом. После этого функция mapper выполняет работу точно таким же образом, добавляя набор атрибутов в класс, причем в данном случае используется атрибут id, а также заменяя атрибуты, которые были добавлены ранее. Ко времени окончания процесса инициализации метакласса (т.е., к моменту, когда поток выполнения покидает блок описания класса User), объект Column с атрибутом id перемещается в новый объект Table и User.id заменяется на новый атрибут, специфичный для отображения.

Всегда считалось, что система SQLAlchemy должна иметь четкую декларативную форму конфигурации. Однако, создание декларативного расширения задерживалось из-за продолжающейся работы по стабилизации механизмов классического отображения. Ранее существовало временное расширение под названием ActiveMapper, которое впоследствии было преобразовано в проект Elixir. Оно позволяло повторно объявлять конструкции отражения в рамках декларативной системы более высокого уровня. Задача декларативного расширения была противоположной задаче проекта Elixir и заключалась в создании мощной абстракции путем создания системы, которая практически полностью сохраняет классические концепции SQLAlchemy в области отображения данных, допуская реорганизацию метода использования для сокращения объема отладочной информации и повышения совместимости с расширениями уровня классов по сравнению с возможностями классических систем отображения.

Вне зависимости от того, используется ли классическое или декларативное отображение, используемый для отображения класс получает новые возможности, которые позволяют ему работать с синтаксическими конструкциями SQL, представленными в форме атрибутов. Изначально принцип использования специального атрибута в качестве источника SQL-запросов для столбцов в SQLAlchemy соответствовал принципу использования такового в SQLObject и заключался в использовании при работе с SQLAlchemy атрибута .c, как показано в этом примере:

result = session.query(User).filter(User.c.username == 'ed').all()

Однако, в версии 0.4 SQLAlchemy эти функции были возложены непосредственно на отображаемые атрибуты:

result = session.query(User).filter(User.username == 'ed').all()

Это изменение метода доступа к атрибутам оказалось значительным усовершенствованием, так как оно позволило аналогичным объектам столбцов объектам находиться в классе для достижения дополнительных, специфичных для класса возможностей, которые не доступны для классов, наследуемых напрямую от находящегося уровнем ниже класса Table. Оно также позволило интегрировать различные типы атрибутов классов, такие, как атрибуты, ссылающиеся непосредственно на столбцы таблицы, атрибуты, ссылающиеся на SQL-запросы, созданные на основе этих столбцов, а также атрибуты, ссылающиеся на соответствующий класс. Наконец, оно позволило достичь соответствия между классом для отображения и экземпляром этого класса для отображения, в котором тот же атрибут может быть предназначен для работы с другой информацией в зависимости от родительского класса. Атрибуты классов возвращают SQL-запросы, в то время, как атрибуты экземпляров классов возвращают реальные данные.

Анатомия отображения

Атрибут id, который был привязан к нашему классу User, является объектом, тип которого известен в рамках языка программирования Python как дескриптор (descriptor), причем этот объект поддерживает методы __get__, __set__ и __del__, которые интерпретатор Python позволяет использовать во всех операциях, связанных с этим классом или его экземпляром. Реализация в рамках SQLAlchemy известна под именем InstrumentedAttribute и мы продемонстрируем механизмы, скрытые за этим фасадным классом, в ходе рассмотрения следующего примера. Начиная работу с описания класса Table, а также пользовательского класса, мы начинаем формирование отображения только для одного столбца, а также используем функцию relationship, которая задает ссылку на связанный класс:

user_table = Table("user", metadata,
    Column('id', Integer, primary_key=True),
)

class User(object):
    pass

mapper(User, user_table, properties={
    'related':relationship(Address)
})

После создания отображения структура относящихся к классу объектов будет соответствовать представленной на Рисунке 20.11.

Анатомия отображения
Рисунок 20.11: Анатомия отображения

На рисунке проиллюстрировано созданное с помощью SQLAlchemy отображение, использующее два отдельных уровня для осуществления взаимодействия заданного пользователем класса и метаданных таблицы, с которой он связан. Инструментарий, предназначенный для реализации функций класса представлен в левой части рисунка, в то время, как функции для работы с базой данных и SQL-запросами - в правой части. Основной используемый шаблон проектирования подразумевает то, что композиция объектов используется для разделения моделей повеления объектов, а наследование объектов используется для разделения вариаций поведения объектов в рамках определенной роли.

В области инструментария класса следует выделить класс ClassManager, который связан с используемым для отображения классом, причем каждый объект InstrumentedAttribute из его коллекции связан с каждым отображаемым атрибутом класса. InstrumentedAttribute также является упомянутым ранее общедоступным дескриптором языка Python и позволяет формировать SQL-запросы при использовании совместно с запросами на основе классов (т.е., User.id==5). При разговоре об экземпляре класса User, следует упомянуть о том, что объект InstrumentedAttribute делегирует поддержку атрибута объекту AttributeImpl, который является одним из нескольких аналогичных объектов, выбранным в соответствии с типом представляемых данных.

Со стороны отображения объект Mapper представляет связь заданного пользователем класса и выбираемого элемента базы данных, чаще всего объекта таблицы Table. Объект Mapper поддерживает коллекцию объектов, известных как MapperProperty и соответствующих каждому из атрибутов, которые отвечают за представление определенного атрибута в SQL-запросе. Наиболее часто встречающимися вариантами объектов MapperProperty являются объекты ColumnProperty, представляющие столбцы в SQL-запросе, а также объекты RelationshipProperty, представляющие связь с другим классом отображения.

Объект MapperProperty делегирует функции загрузки атрибутов, включая функции преобразования атрибутов в фрагменты SQL-запроса и функции их извлечения из строк результатов запросов, объекту LoaderStrategy, который также может быть представлен несколькими вариантами. Различные объекты LoaderStrategies устанавливают методы загрузки атрибута в соответствии с вариантами: deferred (отложенная загрузка), eager (быстрая загрузка) или immediate (немедленная загрузка). Стандартная версия поведения выбирается во время конфигурации отображения, при этом дополнительным вариантом является использование альтернативной стратегии в момент выполнения запроса. Объект RelationshipProperty также ссылается на объект DependencyProcessor, который устанавливает метод обработки зависимостей между отображениями и метод синхронизации атрибутов для применения во время сохранения данных. Выбор объекта DependencyProcessor основывается на параметрах отношения родительских и целевых элементов, связанных друг с другом.

Структура Mapper/RelationshipProperty формирует граф, в котором объекты Mapper являются узлами, а объекты RelationshipProperty - ориентированными ребрами. После того, как полный набор функций отображения декларируется приложением, наступает этап отложенной "инициализации", известный как процесс конфигурации (configuration). Данные, собираемые в ходе выполнения этого процесса, главным образом используются каждым объектом RelationshipProperty для уточнения параметров, используемых функциями отображения родительских (parent) и целевых (target) объектов, причем также производится выбор объекта AttributeImpl наряду с объектом DependencyProcessor. Этот граф является ключевой структурой данных, используемой при выполнении операции объектно-реляционного отображения. Он участвует в операциях, использующих так называемые "каскады", которые устанавливают последовательность выполнения функций на основе последовательностей объектов в операциях запросов, в которых связанные объекты и коллекции объектов "быстро" одновременно загружаются, а также в процессах сохранения данных объектов, когда граф зависимостей для всех объектов создается до того, как будут завершены этапы освобождения выделенных для хранения данных ресурсов.

20.7. Методы выполнения запросов и загрузки данных

SQLAlchemy инициирует все связанные с загрузкой объектов действия с помощью объекта с именем Query. Стандартный процесс инициализации объекта Query начинается с включения примитивов (entites), которые формируют список классов, используемых для отображения и/или индивидуальных SQL-запросов, которые должны быть выполнены. Он также содержит ссылку на объект Session, который представляет соединение с одной или большим количеством баз данных, а также кэш данных, который был собран при осуществлении транзакций в ходе использования этих соединений. Ниже представлен элементарный пример использования этих объектов:

from sqlalchemy.orm import Session
session = Session(engine)
query = session.query(User)

Мы создаем объект Query, который использует экземпляры класса User, относящиеся к новому объекту сессии Session, который мы создали. Объект Query использует генеративный шаблон проектирования builder таким же образом, как и описанная ранее конструкция select, где дополнительные критерии и модификаторы ассоциировались с конструкциями выражений для выполнения одного вызова метода в каждый момент времени. Когда итеративная операция выполняется в отношении объекта Query, он создает конструкцию SQL-запроса SELECT, выполняет этот запрос с помощью базы данных, после чего интерпретирует результирующий набор строк как результирующие данные объектно-реляционного отображения в соответствии с набором изначально запрашиваемых элементов.

Объект Query производит жесткое разделение между частями операции, относящимися к формированию SQL-запросов (SQL rendering) и частями операции, относящимися к загрузке данных (data loading). Первый случай относится к созданию запроса SELECT, а второй - к интерпретации полученных после выполнения SQL-запроса строк как конструкций объектно-реляционного отображения. Фактически данные могут обрабатываться без выполнения шага, заключающегося в формировании SQL-запроса, так как объект Query может интерпретировать результаты выполнения текстового запроса, сформированного пользователем вручную.

И в процессе формирования SQL-запроса, и в процессе загрузки данных используется рекурсивный обход графа, образованного наборами объектов Mapper, причем каждый содержащий данные столбца или SQL-запроса объект ColumnProperty рассматривается как узел, а каждый объект RelationshipProperty, который включается в запрос с помощью так называемой стратегии "eager-load" - как ребро графа, ведущее к другой представленной объектом Mapper вершине. Обход и выполнение действия при достижении каждого из узлов в конечном счете является задачей каждого объекта LoaderStrategy, ассоциированного с каждым объектом MapperProperty, осуществляющим добавление столбцов и объединений к запросу SELECT, создаваемому на этапе формирования запроса и создающему функции языка Python для обработки результирующих строк на этапе загрузки данных.

Все функции языка Python, создающиеся на этапе загрузки данных, получают по строке из базы данных по мере извлечения и в результате производят возможные изменения состояния отображенного атрибута в памяти. Они создаются для определенного атрибута в соответствии с условием, базирующимся на исследовании первой строки из полученного набора результирующих данных, а так же на параметрах загрузки. Если загрузка атрибута не производится, пригодной для вызова функции не создается.

Рисунок 20.12 иллюстрирует обход нескольких объектов LoaderStrategy при сценарии загрузки данных в соответствии со стратегией "joined eager loading" с указанием на их объединение с формируемым SQL-запросом, который появляется в ходе вызова метода _compile_context объекта Query. На нем также показан процесс генерации функций обработки строк (row population functions), которые получают результирующие строки и задают отдельные атрибуты объектов, причем сам этот процесс инициируется с помощью метода instances объекта Query.

Обход объектов при использовании стратегий загрузки, включая стратегию
Рисунок 20.12: Обход объектов при использовании стратегий загрузки, включая стратегию "joined eager load"

Ранее в SQLAlchemy использовался подход, заключающийся в сборе результатов, полученных после вызовов фиксированных, ассоциированных с каждой стратегией методов объектов для получения каждой строки таблицы и выполнения соответствующих действий. Система загрузки с возможностью вызова функций была впервые представлена в версии 0.5 и позволила значительно повысить производительность системы, так как большое количество решений в отношении обработки строк таблицы могло быть принято прямо перед началом их обработки вместо повторного принятия решений для каждой строки, а также большое количество не имеющих эффекта вызовов функций могло быть устранено.

20.8. Сессия и индивидуальное отображение

В SQLAlchemy объект Session представляет публичный интерфейс для непосредственного использования объектно-реляционного отображения, позволяющий загружать и хранить на постоянной основе данные. Он является отправной точкой для выполнения запросов и операций сохранения данных для заданного соединения с базой данных.

В дополнение к поддержке соединения с базой данных, объект Session поддерживает активный список ссылок для набора, состоящего из всех отображенных элементов, которые присутствуют в памяти и относятся к сессии, представленной данным объектом Session. Таким образом, класс Session является фасадным классом, использующим шаблоны проектирования индивидуального отображения (identity map) и рабочей единицы (unit of work), которые также описаны Martin Flower. Индивидуальное отображение позволяет поддерживать уникальное в рамках базы данных отображение всех объектов определенной представленной объектом Session сессии, исключая проблемы, связанные с наличием дубликатов элементов. Рабочая единица является надстройкой над индивидуальным отображением, реализующей систему автоматизации процесса сохранения на постоянной основе всех изменений состояния базы данных наиболее эффективным способом из возможных. Сам этап сохранения данных известен как "этап записи данных", причем в современных версиях SQLAlchemy он обычно выполняется автоматически.

История разработки

Класс Session начал свое существование как наиболее закрытая система, ответственная за выполнение единственной задачи, заключающейся в сохранении данных. Процесс сохранения данных подразумевает выполнение SQL-запросов с помощью базы данных в соответствии с изменениями состояния объектов, отслеживаемых системой рабочей единицы и, в связи с этим, синхронизации текущего состояния базы данных с содержимым памяти. Сохранение данных всегда было одной из наиболее сложных операций, выполняемых SQLAlchemy.

Выполнение операции сохранения данных (flush) в ранних версиях начинается после вызова метода с именем commit, который присутствовал у явно созданного в рамках потока объекта с именем objectstore. Во время использования нами версии 0.1 SQLAlchemy не было особой надобности в вызове метода Session.add, как, впрочем, и не было какой-либо четкой концепции класса сессии Session вообще. Единственными доступными пользователю операциями были операции создания функций для формирования отображений, операции создания новых объектов, операции модификации существующих объектов, загруженных в ходе выполнения запросов (в этом случае сами запросы выполнялись непосредственно через объект Mapper), после чего все данные должны были сохраниться с помощью команды objectstore.commit. Пул объектов для набора операций был безусловно глобальным в рамках модуля и безусловно локальным в рамках потока.

Модель objectstore.commit была популярна среди группы пользователей ранних версий, но отсутствие гибкости этой модели быстро привело к потере актуальности. Пользователи, начавшие работать с новыми версиями SQLAlchemy, иногда жаловались на необходимость создания фабрики и, возможно, реестра для объектов Session, а также на необходимость сохранения своих объектов, организованных в рамках одного объекта Session в каждый момент времени, но этот подход гораздо предпочтительнее существовавшего ранее, при котором принцип работы всех элементов системы был четко задан. Присущие использованному в версии 0.1 шаблону проектирования пользовательские качества все еще в большей степени присутствуют в современных версиях SQLAlchemy, в которых реализован реестр сессий, обычно настроенный для использования локального пространства потока.

Сам объект Session был представлен только в версии 0.2 SQLAlchemy и был смоделирован в общих чертах по аналогии с объектом Session из состава Hibernate. Эта версия содержала интегрированный механизм контроля транзакций, в рамках которого объект Session мог помещаться в транзакцию с помощью метода begin, а завершение транзакции осуществлялось с помощью метода commit. Метод objectstore.commit был переименован в objectstore.flush и новые объекты Session могли создаваться в любой момент. Сам объект Session был отделен от другого объекта с именем UnitOfWork, который остался приватным объектом, ответственным за выполнение операции сохранения данных.

Хотя процесс сохранения данных и был изначально реализован в рамках явно вызываемого пользователем метода, в версии 0.4 SQLAlchemy была представлена концепция автоматического сохранения изменений (autoflush), которая предполагала, что сохранение данных будет осуществляться непосредственно перед каждым запросом. Преимущество автоматического сохранения данных заключается в том, что SQL-запрос, выполняемый в результате запроса всегда имеет доступ со стороны реляционной базы данных к данным, присутствующим в памяти, так как все изменения были переданы. Ранние версии SQLAlchemy не могли включать эту возможность, так как стандартным шаблоном проектирования оговаривалось, что сохранение данных должно сопровождаться полным сохранением изменений. Но в момент представления концепции автоматического сохранения данных была реализована сопутствующая ей возможность под названием "транзакционная сессия" ("transactional session") в рамках объекта Session, заключающаяся в предоставлении объекта Session, который должен был автоматически начинать транзакцию, продолжавшуюся до того момента, когда пользователь явно вызывал метод commit. После реализации этой возможности метод flush больше не записывал данные для сохранения и мог вызываться автоматически. Сейчас объект Session позволяет осуществлять пошаговую синхронизацию между данными состояния в памяти и данными состояния SQL-запроса путем сохранения данных при необходимости без постоянного сохранения данных до момента явного вызова метода commit. Такое поведение фактически точно повторяет поведение системы Hibernate для языка программирования Java. Однако, этот стиль работы был реализован в SQLAlchemy благодаря использованию в качестве примера системы Storm ORM для языка программирования Python, представленной в момент существования версии 0.3 системы SQLAlchemy.

В версии 0.5 была улучшена работа с транзакциями и представлена схема истечения срока действия транзакции (post-transaction expiration); после каждого использования методов commit и rollback по умолчанию истекал срок действия всех данных состояния в рамках объекта Session (они удалялись) и они должны были снова извлекаться в ходе выполнения последующих SQL-запросов или тогда, когда доступ к атрибутам оставшегося набора объектов с истекшим сроком действия осуществляется из контекста новой транзакции. Изначально система SQLAlchemy была спроектирована в соответствии с предположением о том, что запросы SELECT должны безусловно выполняться так мало раз, как это возможно. Стратегия истечения срока действия данных при их записи внедрялась медленно именно по этой причине; однако, она полностью решала проблему хранения в рамках объекта Session устаревшей копии полученных после транзакции данных, для обновления которой не было предложено простого, не требующего полномасштабного повторного создания набора уже загруженных объектов способа. Сначала казалось, что эта проблема не имеет разумного решения, так как момент, после которого объект Session должен считать текущие данные состояния устаревшими и, следовательно, использовать набор ресурсоемких запросов SELECT при следующей попытке доступа к данным, не был очевиден. Однако, как только объект Session начал постоянно использоваться в рамках транзакций, момент завершения транзакции стал естественным четким моментом истечения срока действия данных, так как природа транзакции с высокой степенью изоляции состоит в том, что она не может получить доступ к новым данным до того, как ее данные будут записаны, либо она будет отменена. Различные базы данных и конфигурации, конечно же, характеризуются различными степенями изоляции транзакций, включая отсутствие транзакций как таковых. Эти режимы работы полностью допустимы при использовании модели истечения срока действия данных SQLAlchemy; разработчик должен заботиться только о том, чтобы низкая степень изоляции не привела к раскрытию неизолированных изменений в рамках сессии в том случае, когда множество сессий использует одни и те же строки. Эта ситуация ни коим образом не отличается от ситуации, которая может произойти при непосредственном использовании двух соединений с базой данных.

Обзор сессии

Рисунок 20.13 иллюстрирует объект Session и основные структуры, взаимодействующие с ним.

Обзор объекта Session
Рисунок 20.13: Обзор объекта Session

Общедоступными объектами на рисунке выше является сам объект Session, а также коллекция пользовательских объектов, каждый из которых является экземпляром класса, используемого для создания отображения. Здесь мы можем увидеть, что используемые для отображения объекты ссылаются на конструкцию из состава SQLAlchemy с именем InstanceState, которая отслеживает состояние отдельного объектно-реляционного отображения, включая ожидающие операции изменения атрибутов, а также факт истечения срока действия атрибутов. Объект InstanceState является инструментарием для работы с атрибутами на уровне экземпляра класса, описанным в предыдущем разделе под названием "Анатомия отображения" и соответствующим объекту ClassManager на уровне класса, который позволяет поддерживать состояние словаря используемого для создания отображения объекта (т.е., атрибута __dict__, описанного в рамках языка программирования Python) на стороне ассоциированных с классом объектов AttributeImpl.

Отслеживание состояния

Объект IdentityMap позволяет создавать отображение индивидуальных данных базы данных для объектов InstanceState, которые в свою очередь используются теми объектами, которым требуются эти индивидуальные данные, называемые также постоянными (persistent). Стандартная реализация объекта IdentityMap взаимодействует с объектом InstanceState для самостоятельного управления объемом занятой памяти путем удаления созданных пользователем отображений в тех случаях, когда удаляются все жесткие ссылки на эти отображения - таким образом, этот объект функционирует аналогично объекту WeakValueDictionary из состава Python. Объект Session защищает набор всех объектов с пометкой "устаревший" ("dirty") или "удаленный" ("deleted"), а также охраняет объекты с пометкой "новый" ("new") от механизма сборки мусора путем создания жестких ссылок на эти объекты в случае ожидания их изменений. Все жесткие ссылки удаляются после выполнения операции сохранения данных.

Объект InstanceState также выполняет критичную задачу, заключающуюся в поддержании "списка изменений" для атрибутов определенного объекта с использованием системы перемещения данных при изменении, которая сохраняет "данные предыдущего состояния" определенного атрибута в словаре с именем commited_state перед использованием переданного значения для изменения значения в словаре атрибутов объекта. Во время выполнения операции сохранения изменений содержимое словаря commited_state, а также ассоциированного с объектом словаря __dict__ сравниваются с целью формирования набора измененных данных для каждого из объектов.

В случае коллекций отдельный пакет с именем collections осуществляет координацию работы системы объектов InstrumentedAttribute/InstanceState для поддержания функционирования коллекции изменений для определенной коллекции объектов, используемых для отображения. Такие стандартные классы языка Python, как set, list и dict перед использованием объявляются подклассами и принимают в качестве аргументов методы, предназначенные для отслеживания истории изменений. Система коллекций была переработана в версии 0.4 с целью расширения ее возможностей в плане использования любых аналогичных коллекциям объектов.

Контроль транзакций

Объект Session при обычном сценарии использования поддерживает открытую транзакцию для выполнения всех операций, которая завершается в момент вызова метода commit или rollback. Объект SessionTransaction поддерживает набор объектов Connection, который может быть как пустым, так и заполненным, причем каждый объект в нем представляет открытую транзакцию для определенной базы данных. Объект SessionTransaction является объектом с отложенной инициализацией, которая начинается при отсутствии данных состояния базы данных. Так как определенная база данных должна участвовать в процессе выполнения запроса, соответствующий этой базе данных объект соединения Connection добавляется в список соединений объекта SessionTransaction. Хотя обычно в каждый момент времени используется одно соединение с базой данных, поддерживается сценарий использования множества соединений, в котором определенное соединение используется для выполнения определенной операции, в соответствии с ассоциированными с объектами Table, Mapper данными конфигурации, либо в соответствии с конструкциями языка SQL, применяемыми в рамках операции. При использовании множества соединений также может координироваться процесс выполнения транзакции при применении двухфазной схемы в тех случаях, когда реализация DBAPI предоставляет ее.

20.9. Рабочая единица

Метод flush объекта Session реализован в рамках отдельного модуля с именем unitofwork. Как упоминалось ранее, процесс сохранения данных, скорее всего, является наиболее сложной функцией, реализованной в SQLAlchemy.

Задачей рабочей рабочей единицы является перемещение данных из всех ожидающих обработки объектов, присутствующих в коллекции определенного объекта Session в базу данных с очисткой коллекций новых (new), устаревших (dirty) и удаленных (deleted) объектов, обрабатываемых объектом Session. После завершения этой работы находящиеся в памяти данные состояния объекта Session и данные текущей транзакции будут совпадать. Основной трудностью является установление корректной последовательности операций сохранения данных и последующее их выполнение в нужном порядке. Эта задача включает в себя создание списка запросов INSERT, UPDATE и DELETE, включая те запросы, которые были созданы в результате выполнения каскада операций, направленных на удаление или перемещение соответствующих строк; проверку того, что запросы UPDATE содержат только те столбцы, которые были действительно изменены; выполнение операций "синхронизации", в ходе которых будут скопированы данные состояния столбцов с первичными ключами в столбцы с ссылающимися на них внешними ключами в момент, когда заново сгенерированные идентификаторы в форме первичных ключей станут доступны; проверку того, что запросы INSERT используются в том же порядке, в каком объекты с ними были добавлены в коллекцию объекта Session, причем они должны использоваться настолько эффективно, насколько это возможно; а также проверку того, что запросы UPDATE и DELETE используются в корректном порядке для сокращения вероятности блокировок.

История

Реализация рабочей единицы была начата в форме запутанной системы из структур, возможности которой расширялись бессистемно в каждом отдельном случае; процесс ее разработки может сравниваться с процессом поиска выхода из леса без карты. Ранние ошибки и недостатки функций устранялись путем внесения специфических исправлений и, несмотря на улучшение ситуации после нескольких рефакторингов до версии 0.5, в версии 0.6 модуль рабочей единицы со стабилизированным, хорошо изученным и к тому времени снабженным сотнями тестов кодом должен был быть полностью переработан. После многих недель формирования нового подхода, в рамках которого должны были быть описаны стандартные структуры данных, сам процесс переписывания кода для использования этой новой модели занял всего несколько дней, так как к тому времени идея новой модели была понятна разработчикам. Фактически процесс разработки был значительно упрощен благодаря тому, что принцип работы новой реализации должен был тщательно сопоставляться и приводиться к принципу работы существующей версии. Этот процесс продемонстрировал, что несмотря на то, насколько первая реализация чего-либо является непродуманной, она все же очень ценна, так как является рабочей моделью. Кроме того, он демонстрирует, что полная переработка подсистемы не только допустима, но и является неотъемлемой частью процесса разработки сложных в реализации программных компонентов.

Топологическая сортировка

Ключевой парадигмой, использованной при создании рабочей единицы, является формирование полного списка действий для последующего выполнения в рамках структуры данных, причем каждый элемент этого списка будет представлять отдельный шаг; в области шаблонов проектирования этому подходу соответствует шаблон команд (command pattern). Позднее серии "команд" в рамках этой структуры данных располагаются в специфической последовательности с помощью топологической сортировки (topological sort). Топологическая сортировка представляет собой процесс, в ходе которого происходит сортировка элементов списка путем их частичного упорядочивания (partial ordering), т.е., в этом случае только определенные элементы списка должны быть расположены перед остальными. Рисунок 20.14 иллюстрирует описанный процесс топологической сортировки.

Топологическая сортировка
Рисунок 20.14: Топологическая сортировка

Рабочая единица выполняет частичное упорядочивание тех команд сохранения данных, которые должны предшествовать всем остальным. После того, как команды топологически отсортированы, они будут по очереди выполнены. Определение того, какие команды должны предшествовать другим командам, в основном осуществляется путем обнаружения результата выполнения функции relationship, которая связывает два объекта Mapper - в общем случае один объект Mapper рассматривается как зависящий от другого объекта, так как функция relationship устанавливает зависимость одного объекта Mapper от внешнего ключа другого объекта. Существуют похожие правила для установления таблиц соответствия между множествами объектов с обоих сторон, но в данном случае мы будем рассматривать случай использования отношений один ко многим/многие к одному. Зависимости от внешних ключей разрешаются последовательно для предотвращения появления нарушений в области ограничений без необходимости присвоения ограничениям метки "отложенное". Но, что не менее важно, сортировка позволяет использовать первичные ключи, которые генерируются на многих платформах только непосредственно в ходе выполнения запроса INSERT, путем извлечения их из набора результирующих данных только что выполненного запроса INSERT и вставки в список параметров зависимого запроса для добавления строки. В случае удаления строк данная сортировка производится в обратном порядке - зависимые строки удаляются до того, как происходит удаление строк, от которых они зависят, так как эти строки не будут доступны без наличия внешних ключей, ссылающихся на них.

Рабочая единица реализует систему, в рамках которой топологическая сортировка выполняется на двух различных уровнях, выделяемых на основе структуры имеющихся зависимостей. На первом уровне шаги сохранения данных распределяются между "корзинами" на основе зависимостей между объектами отображения таким образом, что полные "корзины" объектов соответствуют определенному классу. На втором уровне вообще не происходит разделения или происходит разделение одной или нескольких "корзин" на небольшие последовательности объектов с целью обработки случаев использования циклических ссылок или ссылающихся самих на себя таблиц. Рисунок 20.15 иллюстрирует "корзины", сгенерированные для вставки набора объектов User, с последующей вставкой набора объектов Address, причем на промежуточном шаге должно быть осуществлено копирование только что сгенерированных значений первичных ключей для объектов User в столбец внешних ключей с именем user_id для каждого из объектов Address.

Организация объектов на основе объектов отображения
Рисунок 20.15: Организация объектов на основе объектов отображения

В ситуации, когда используется сортировка на основе объектов отображения, любое количество объектов User и Address может быть сохранено без увеличения сложности шагов или изменения количества "зависимостей", которые должны быть рассмотрены.

На втором уровне сортировки шаги сохранения данных организуются на основе прямых зависимостей между отдельными объектами в рамках области действия одного объекта отображения. Простейшим примером возникновения такой ситуации является таблица, которая содержит внешний ключ, ссылающийся на себя; а также определенная строка, которая должна быть вставлена в таблицу перед другой строкой, ссылающейся на эту строку. Другим примером является набор таблиц с циклическими ссылками (reference cycle): таблица A ссылается на таблицу B, которая в свою очередь ссылается на таблицу A. В этом случае некоторые объекты таблицы A должны быть вставлены перед другими объектами для того, чтобы также имелась возможность вставки объектов в таблицы B и C. Ссылающаяся сама на себя таблица является частным случаем циклической ссылки.

Для установления того, какие операции могут остаться в сформированных на основе объектов Mapper корзинах, а также того, какие корзины будут разделены на большие наборы команд для объектов, по отношению к набору зависимостей между объектами отображения применяется алгоритм определения циклических зависимостей, который является модифицированной версией алгоритма определения циклических зависимостей из блога Guido Van Rossum. Те корзины, в которых обнаружены циклические зависимости, впоследствии разделяются на операции, выполняемые по отношению к объектам, и добавляются в коллекцию разделенных на основе объектов отображения корзин путем добавления новых зависимостей от объектов из корзин, разделенных на основе объектов, в объекты из корзин, разделенных на основе объектов отображения. Рисунок 20.16 иллюстрирует процесс разделения корзины для объектов User на отдельные команды объектов путем использования функции relationship для указания на зависимость объекта User от своего атрибута contact.

Преобразование циклических ссылок в отдельные шаги процесса
Рисунок 20.16: Преобразование циклических ссылок в отдельные шаги процесса

Обоснованием применения структуры корзин является возможность упорядочивания стандартных запросов настолько, насколько это возможно, путем сокращения числа шагов, выполняемых с использованием функций языка Python, а также возможность осуществления более эффективных взаимодействий с реализацией DBAPI, которые иногда позволяют выполнить тысячи запросов в рамках вызова одного метода на уровне языка Python. Более сложный метод определения индивидуальных зависимостей для объектов используется лишь в случае существования циклических ссылок между объектами отображения, но этот метод используется исключительно в тех частях графа объектов, где он требуется.

20.10. Заключение

С самого начала работы над SQLAlchemy перед разработчиками были поставлены значительные задачи, причем главной целью разработки было создание программного продукта для работы с базами данных, обладающего настолько большим набором возможностей и являющегося настолько гибким, насколько это возможно. Это задача была выполнена в ходе работы над поддержкой реляционных баз данных, так как было понятно, что всеобъемлющая и проработанная поддержка реляционных баз данных является основной задачей; и даже сейчас масштабы данной работы являются гораздо большими, чем казалось ранее.

Основывающийся на компонентах подход был предназначен для извлечения возможной пользы из каждой области возможностей путем предоставления множества различных элементов, которые приложения могли использовать по отдельности или комбинировать. Эту систему было интересно создавать, поддерживать и внедрять.

Для разработки был осознанно выбран медленный курс, основывающийся на предположении о том, что методичная, всесторонняя проработка основных функций в конечном итоге окажется более удачной, нежели быстрая реализация функций без должной проработки. Потребовалось много времени для того, чтобы система SQLAlchemy стала последовательной и хорошо документированной с точки зрения пользователя, но в ходе процесса разработки архитектура проекта всегда была на шаг впереди, что в некоторых случаях приводило к проявлению эффекта "машины времени", при котором функции могли добавляться до того, как пользователи запрашивали их.

Язык программирования Python был надежной основой (если быть немного привередливым, то можно отметить, в частности, область производительности). Последовательность и в значительной степени открытая модель времени выполнения позволили лучше реализовать в рамках SQLAlchemy те возможности, которые предоставлялись аналогичными программными продуктами, разработанными с использованием других языков программирования.

Участники проекта SQLAlchemy надеются, что язык программирования Python получит еще большее распространение в таком широком спектре областей и на таком большом количестве предприятий, как это возможно, а также на то, что масштабы использования реляционных баз данных останутся значительными и будут расширяться. Целью проекта SQLAlchemy является демонстрация того, что реляционные базы данных, язык программирования Python и хорошо продуманные объектные модели являются очень ценными инструментами разработчиков.

21.1. Почему Twisted?

В 2000 году человек по прозвищу glyph, являющийся создателем фреймворка Twisted, работал над текстовой многопользовательской игрой с названием "Twisted Reality" ("Запутанная реальность"). Она была реализована с помощью нагромождения потоков, по 3 потока на каждое соединение, причем для разработки был выбран язык Java. В рамках соединения присутствовал поток ввода, блокирующийся операциями чтения, поток вывода, который должен был блокироваться определенным типом операций записи, а также поток "обработки логики", который должен был бездействовать, ожидая истечения времени таймеров или поступления событий в очередь. По мере передвижения игроков по виртуальному пространству и их взаимодействия, происходили взаимные блокировки потоков, портились данные в кэшах и логика блокировок никогда не была достаточно хорошо проработана - использование потоков делало программное обеспечение запутанным, наполненным ошибками и трудно масштабируемым.

В поисках альтернатив, он столкнулся с языком программирования Python, а именно, с модулем Python select, предназначенным для мультиплексирования операций ввода/вывода таких объектов, работающих с потоками данных, как сокеты и программные каналы (спецификация Single UNIX Specification, Version 3 (SUSv3) описывает функцию API для вызова select). В то время в рамках языка Java не осуществлялось раскрытие интерфейса select операционной системы, как и любых других API для осуществления операций асинхронного ввода/вывода (пакет java.nio для неблокирующих операций ввода/вывода был добавлен в комплект поставки J2SE 1.4, причем эта версия была выпущена в 2002 году). Начальный прототип игры, созданный с использованием языка Python и метода select, сразу же послужил доказательством меньшей запутанности кода и большей надежности по сравнению с версией на основе потоков.

В ходе преобразования кода для использования языка Python, метода select и парадигмы управления событиями, glyph разработал клиент и сервер игры на языке Python с использованием API select. Но после этого ему захотелось большего. В качестве фундаментальной идеи он рассматривал возможность перевода операций взаимодействия с сетью в рамки вызовов методов объектов игры. А что, если вы сможете получать сообщения электронной почты в игре, также, как это реализовано в почтовом демоне Nethack? А если у каждого игрока будет возможность создать домашнюю страницу? Glyph понял, что ему необходимы хорошее клиенты и серверы для протоколов IMAP и HTTP, разработанные с использованием языка программирования Python и метода select.

Изначально он использовал платформу Medusa, созданную в середине 90 годов для разработки сетевых серверов на языке Python и основанную на модуле asyncore. Модуль asyncore реализует механизм работы с сокетом, в рамках которого поверх API select операционной системы создается интерфейс, состоящий из диспетчера и функции обратного вызова.

Эта находка вдохновила разработчика glyph, но платформа Medusa имела два недостатка:

  1. В 2001 году, когда gliph начал работу над Twisted Reality, ее развитие практически не поддерживалось.
  2. Модуль asyncore был такой тонкой прослойкой над функциями для работы с сокетами, что разработчикам приложений все еще приходилось напрямую производить манипуляции с ними. Это означало, что о портируемости кода должен был заботиться разработчик. К тому же, в то время поддержка платформы Windows в рамках модуля asyncore была реализована с ошибками, а glyph был уверен в том, что ему было необходимо иметь возможность запускать графический интерфейс клиента на платформе Windows.

Glyph столкнулся с перспективой самостоятельной реализации платформы для работы с сетью и решил, что игра Twisted Reality выявила проблему, решение которой было не менее интересным, чем разработка самой игры.

Со временем игра Twisted Reality превратилась в платформу для работы с сетью Twisted, которая могла выполнять действия, не доступные для других сетевых платформ языка Python:

21.2. Архитектура Twisted

Twisted является управляемым событиями сетевым фреймворком. Парадигма управляемого событиями программирования является настолько неотъемлемой частью философии проектирования Twisted, что стоит уделить некоторое время обзору того, что на самом деле понимают под парадигмой управляемого событиями программирования.

Управляемое событиями программирование осуществляется в соответствии с парадигмой, определяющей то, что ход выполнения программы задается внешними событиями. Для него характерно использование цикла приема событий и использование функций обратного вызова с целью выполнения действий в моменты наступления событий. Другим двумя стандартными парадигмами программирования являются синхронное (однопоточное) и многопоточное программирование.

Давайте сравним и найдем отличия между однопоточной, многопоточной и управляемой событиями моделями программирования с помощью примера. На Рисунке 21.1 представлена выполняемая программой работа с течением времени в случае использования каждой из этих трех моделей. Программа должна выполнить три задачи, причем каждая из этих задач блокирует выполнение программы до момента завершения операции ввода/вывода. Время, потраченное при блокировании выполнения программы для завершения операций ввода/вывода, показано с помощью областей серого цвета.

Модели сетевого программирования
Рисунок 21.1: Модели сетевого программирования

В однопоточной синхронной версии программы задачи выполняются по очереди. В том случае, если одна задача блокируется для выполнения операций ввода-вывода, всем другим задачам приходится ожидать окончания выполнения операций и приступать к работе по очереди. Этот определенный порядок и последовательное выполнение задач упрощают понимание принципа работы программы, но при этом программа становится чрезмерно медленной в том случае, когда задачи не зависят друг от друга, но им все равно приходится ожидать друг друга.

В многопоточной версии программы три задачи с блокировками во время выполнения работы исполняются в отдельных программных потоках. Эти потоки управляются операционной системой и могут исполняться одновременно на множестве процессоров или поочередно на одном процессоре. Это позволяет выполнять работу некоторым потокам в тот момент, когда другие потоки блокируются для обработки ресурсов. Обычно эта программа более оптимальна с точки зрения времени, чем аналогичная синхронная программа, но приходится разрабатывать дополнительный код для защиты разделяемых потоками ресурсов, доступ к которым может осуществляться одновременно из нескольких потоков. Принцип работы многопоточных программ может оказаться более сложным для понимания, так как в случае реализации таких программ приходится заботиться о безопасной работе потоков с помощью механизмов сериализации процессов (блокировок), использования реентерантных функций, локальных хранилищ данных для потоков или других механизмов, которые в случае некорректного применения могут приводить к скрытым и опасным ошибкам.

Управляемая событиями версия программы поочередно выполняет три задачи, но в рамках одного потока. При выполнении операций ввода/вывода или других затрачивающих время операций, в цикле обработки событий регистрируется функция обратного вызова и выполнение задачи продолжается после завершения операции ввода/вывода. Функция обратного вызова устанавливает метод обработки события сразу же после завершения операции. Цикл обработки событий ожидает события и распределяет их в порядке поступления для обработки с помощью функций обратного вызова, которые их ожидают. Это обстоятельство позволяет программе выполнять работу всегда, когда это возможно без необходимости использования множества дополнительных потоков. Управляемые событиями программы могут быть более простыми для понимания, нежели многопоточные программы, так как разработчику не приходится заботиться о безопасности функционирования потоков.

Управляемая событиями модель обычно является хорошим выбором, если:

  1. имеется множество задач, которые...
  2. в значительной степени независимы (поэтому им не нужно взаимодействовать друг с другом или ожидать друг друга), а также...
  3. некоторые из этих задач блокируются во время ожидания событий.

Также эта модель является хорошим выбором в том случае, когда приложению требуется предоставлять задачам доступ к изменяемым данным, так как в этом случае не требуется синхронизации.

Приложения для работы с сетью обычно имеют именно эти свойства, что и определяет их хорошую совместимость с управляемой событиями моделью программирования.

Повторное использование существующих приложений

Многие популярные клиенты и серверы для различных сетевых протоколов существовали до того, как был создан фреймворк Twisted. Почему же glyph просто не использовал Apache, IRCd, BIND, OpenSSH или любое другое из ранее существующих приложений, клиенты и серверы из которых были с нуля реализованы в рамках Twisted?

Проблема состоит в том, что все эти реализации серверов обычно использовали разработанный с нуля с использованием языка программирования C код для взаимодействия с сетью, причем код приложения был напрямую связан с кодом сетевого уровня. Это обстоятельство в значительной степени затрудняло использование существующего кода в форме библиотек. Эти программные компоненты должны были совместно использоваться как черные ящики, не позволяя разработчику повторно использовать код в том случае, если он или она пожелали передать одни и те же данные с использованием множества протоколов. К тому же, реализации серверов и клиентов обычно являются отдельными приложениями, которые совместно не используют код. Расширение возможностей этих приложений и поддержка кроссплатформенной клиент-серверной совместимости оказываются более сложными задачами, чем должны.

В случае использования Twisted клиенты и серверы разрабатываются с использованием языка Python и постоянного интерфейса. Это обстоятельство упрощает разработку клиентов и серверов, совместное использование кода для реализации клиентов и серверов, использование одной и той же логики приложения для работы с различными протоколами, а также тестирование кода.

Шаблон проектирования reactor

Twisted реализует шаблон проектирования reactor, который описывает возможность демультиплексирования и распределения событий от множества источников между их обработчиками в однопоточном окружении.

Основной частью фреймворка Twisted является цикл обработки событий, спроектированный согласно шаблону reactor. Этот цикл располагает информацией о событиях сети, файловой системы и таймеров. Он ожидает и при наступлении обрабатывает эти события, абстрагируясь от специфичного для платформы поведения и представляя интерфейсы для осуществления ответа на события в любой точке сетевого стека без сложностей.

Цикл обработки событий в основном выполняет следующие действия:

while True:
    timeout = time_until_next_timed_event()
    events = wait_for_events(timeout)
    events += timed_events_until(now())
    for event in events:
        event.process()

Цикл обработки событий основывается на API poll (описанном в спецификации Single UNIX Specification, Version 3 (SUSv3)), используемом в качестве стандарта для всех платформ. Дополнительно Twisted поддерживает несколько платформо-специфичных гибко масштабируемых API для мультиплексирования. Платформо-специфичные циклы обработки событий могут основываться на KQueue, методе мультиплексирования, на основе механизма kqueue из состава FreeBSD, epoll в системах с поддержкой интерфейса epoll (на данный момент это Linux 2.6) и IOCP на основе технологии Windows Input/Output Completion Ports.

Примеры зависящих от реализации особенностей процесса ожидания событий, рассматриваемых Twisted, включают следующие:

Реализация циклов обработки событий в рамках Twisted также заботится о корректном использовании низкоуровневых не использующих блокировок API, а также о корректной работе в случае нестандартных критических ситуаций. В рамках языка Python API IOCP не раскрывается вообще, поэтому Twisted использует свою собственную реализацию.

Управление цепочками функций обратного вызова

Функции обратного вызова являются фундаментальной частью процесса разработки управляемых событиями систем и способом указания со стороны цикла обработки событий на факт наступления событий. По мере роста управляемых событиями программ, обработка и удачных и неудачных вариантов событий в рамках приложения значительно усложняется. Невозможность регистрации подходящей функции обратного вызова может привести к блокировке программы на операции обработки события, которая никогда не будет выполнена , при этом ошибки могут распространяться по цепочке функций обратного вызова от сетевого стека через уровни приложения.

Давайте рассмотрим некоторые ловушки, появляющиеся в управляемых событиями программах, в ходе сравнения синхронной и асинхронной версии игрушечной утилиты для получения страницы на основе строки URL, разработанной с использованием похожего на Python псевдокода:

Синхронная версия программы для получения страницы на основе строки URL:

import getPage

def processPage(page):
    print page

def logError(error):
    print error

def finishProcessing(value):
    print "Завершение работы..."
    exit(0)

url = "http://google.com"
try:
    page = getPage(url)
    processPage(page)
except Error, e:
    logError(error)
finally:
    finishProcessing()

Асинхронная версия программы для получения страницы на основе строки URL:

from twisted.internet import reactor
import getPage

def processPage(page):
    print page
    finishProcessing()

def logError(error):
    print error
    finishProcessing()

def finishProcessing(value):
    print "Завершение работы..."
    reactor.stop()

url = "http://google.com"
# Функция getPage принимает следующие аргументы: строку url, 
#    функцию обратного вызова для использования в случае удачного получения страницы, 
#    функцию обратного вызова для обработки ошибки
getPage(url, processPage, logError)

reactor.run()

В случае асинхронной программы для получения страницы на основе URL вызов reactor.run() запускает цикл обработки сообщений. И в синхронной, и в асинхронной версиях гипотетическая функция getPage выполняет работу по получению страницы. Функция processPage вызывается в случае успешного получения страницы, а функция logError вызывается в случае возникновения исключения (Exception) при попытке получения страницы. В любом случае после этого вызывается функция finishProcessing.

Вызов функции logError из асинхронной версии заменяется на часть except блока try/except в синхронной версии. Вызов функции processPage заменяет оператор else и безусловный вызов функции finishProcessing заменяет оператор finally.

В синхронной версии с помощью структуры блока try/except, вызывается только одна из функций logError и processPage и функция finishProcessing всегда вызывается единожды; в асинхронной версии разработчик ответственен за использование корректных цепочек функций обратного вызова для обработки удачного или неудачного завершения процесса. Если бы в случае ошибки программирования вызов функции finishProcessing не использовал функции processPage и logError вместе с соответствующими цепочками функций обратных вызовов, цикл обработки событий не прервал бы никогда свою работу и программа выполнялась бы вечно.

Этот простейший пример указывает на сложности, которые расстраивали разработчиков Twisted в течение первых лет работы над проектом. Ответом на эти сложности послужило увеличение размеров объекта с названием Deferred.

Объекты Deferred

Объект Deferred является абстракцией над результатом, которого на данный момент не существует. Он также облегчает управление цепочками функций обратного вызова, используемыми для получения этого результата. При возврате из функции объект Deferred выступает в роли обещания того, что функция вернет результат на определенном шаге. Этот возвращенный объект Deferred содержит ссылки на все функции обратного вызова, зарегистрированные для данного события, поэтому только этот единственный объект должен передаваться между функциями и гораздо проще работать с ним, а не управлять отдельными функциями обратного вызова.

Объектам Deferred соответствуют пары цепочек функций обратных вызовов, одна для обработки успешных операций (с помощью функций обратного вызова для обработки данных) и одна для обработки ошибок (с помощью специальных функций обратного вызова для обработки ошибок). Объекты Deferred создаются с двумя изначально пустыми цепочками. После этого добавляются пары функций обратного вызова для обработки данных и ошибок, которые будут использоваться для обработки удачных и неудачных операций в каждой точке процесса обработки событий. В тот момент, когда осуществляется асинхронная доставка результата выполнения операции, "активизируется" объект Deferred и вызываются соответствующие функции обратного вызова для обработки данных и событий в том порядке, в котором они были добавлены.

Ниже приведена использующая объекты Deferred версия асинхронной программы для получения страницы на основе URL в псевдокоде:

from twisted.internet import reactor
import getPage

def processPage(page):
    print page

def logError(error):
    print error

def finishProcessing(value):
    print "Завершение работы..."
    reactor.stop()

url = "http://google.com"
deferred = getPage(url) # Функция getPage возвращает объект Deferred
deferred.addCallbacks(success, failure)
deferred.addBoth(stop)

reactor.run()

В этой версии вызываются те же обработчики событий, но все они регистрируются с помощью одного объекта Deferred вместо распределения по коду и передачи имен функций в качестве аргументов функции getPage.

Объект Deferred создается с двумя уровнями функций обратного вызова. Во-первых, функция addCallbacks добавляет функцию обработки данных processPage и функцию обработки ошибок logError на первый уровень их соответствующих цепочек. После этого с помощью функции addBoth функция обратного вызова finishProcessing добавляется на второй уровень обеих цепочек.

Цепочки функций обратного вызова в форме диаграммы выглядят примерно так, как показано на Рисунке 21.1.

Цепочки функций обратного вызова
Рисунок 21.1: Цепочки функций обратного вызова

Объекты Deferred могут быть активизированы только однократно; повторная попытка их активизации приведет к возникновению исключения (Exception). Это позволяет добиться семантики объектов Deferred, аналогичной семантике блоков try/except в синхронных версиях, которые упрощают понимание процесса обработки асинхронных событий и позволяют избежать коварных ошибок, вызванных тем, что функции обратного вызова вызываются больше одного раза для события.

Понимание принципов использования объектов Deferred является важным для понимания принципа работы программ на основе Twisted. Однако, при использовании высокоуровневых абстракций, предоставляемых фреймворком Twisted для сетевых протоколов, обычно вообще не приходится использовать объекты Deferred напрямую.

Абстракция Deferred является достаточно мощной и была заимствована другими управляемыми событиями платформами, в числе которых jQuery, Dojo и Mochkit.

Транспорты

Транспорты представляют соединение между двумя конечными точками, взаимодействующими посредством сети. Транспорты отвечают за описание параметров соединения, таких, как является ли соединение потоко- или дейтаграммно-ориентированным, какие алгоритмы применяются для контроля потока, а также какова надежность соединения. TCP-, UDP- и Unix-сокеты являются примерами транспортов. Они спроектированы так, чтобы быть "минимально функциональными примитивами, которые могут быть максимально используемыми повторно" и отделены от реализаций протоколов, позволяя множеству протоколов использовать один и тот же тип транспорта. Транспорты реализуют интерфейс ITransport, который имеет следующие методы:

write Записать последовательно какие-либо данные в физическое соединение без блокировок.
writeSequence Записать список строк в физическое соединение.
loseConnection Записать все ожидающие данные, после чего закрыть соединение.
getPeer Получить удаленный адрес данного соединения.
getHost Получить локальный адрес данного соединения.

Отделение транспортов от протоколов также упрощает тестирование двух уровней. Исследуемый транспорт может просто записать данные в строку для проверки.

Протоколы

Протоколы устанавливают методы асинхронной обработки событий сети, HTTP, DNS и IMAP являются примерами прикладных сетевых протоколов. Протоколы реализуют интерфейс IProtocol, который имеет следующие методы:

makeConnection Создать соединение для транспорта с сервером.
connectionMode Вызывается тогда, когда соединение создано.
dataReceived Вызывается тогда, когда приняты данные.
connectionLost Вызывается при закрытии соединения.

Связь между циклом обработки событий, протоколами и транспортами может быть лучшим образом проиллюстрирована с помощью примера. Ниже приведены завершенные реализации эхо-сервера и клиента, сначала код сервера:

from twisted.internet import protocol, reactor

class Echo(protocol.Protocol):
    def dataReceived(self, data):
        # Любые данные после приема должны быть отправлены назад
        self.transport.write(data)

class EchoFactory(protocol.Factory):
    def buildProtocol(self, addr):
        return Echo()

reactor.listenTCP(8000, EchoFactory())
reactor.run()

И код клиента:

from twisted.internet import reactor, protocol

class EchoClient(protocol.Protocol):
   def connectionMade(self):
       self.transport.write("hello, world!")

   def dataReceived(self, data):
       print "Ответ сервера:", data
       self.transport.loseConnection()

   def connectionLost(self, reason):
       print "соединение разорвано"

class EchoFactory(protocol.ClientFactory):
   def buildProtocol(self, addr):
       return EchoClient()

   def clientConnectionFailed(self, connector, reason):
       print "Не удалось осуществить соединение - всего доброго!"
       reactor.stop()

   def clientConnectionLost(self, connector, reason):
       print "Соединение разорвано - всего доброго!"
       reactor.stop()

reactor.connectTCP("localhost", 8000, EchoFactory())
reactor.run()

В ходе выполнения сценария сервера должен начать работу TCP-сервер, ожидающий соединений на порту 8000. Сервер использует протокол Echo и данные передаются с помощью транспорта TCP. В ходе выполнения сценария клиента с сервером организуется соединение по протоколу TCP, после чего серверу передаются данные, которые он присылает назад, соединение закрывается, а цикл обработки событий завершает свою работу. Фабрики протоколов (EchoFactory) используются для создания экземпляров классов протоколов на двух сторонах соединения. С двух сторон соединения производится асинхронный обмен данными; метод connectTCP осуществляет регистрацию функций обратного вызова в цикле обработки событий для получения уведомлений о том, что данные доступны для чтения из сокета.

Приложения

Twisted является фреймворком для создания масштабируемых, кроссплатформенных сетевых серверов и клиентов. Упрощение процесса развертывания этих приложений в соответствии со стандартом в промышленных окружениях является важным шагом для подобной платформы, направленным на расширение ее распространения.

Для этого в рамках проекта Twisted была разработана инфраструктура приложений Twisted, предоставляющая универсальный и конфигурируемый способ развертывания приложения на основе Twisted. Она позволяет разработчику избежать использования шаблонного кода, интегрируя приложение с существующими инструментами для изменения принципа его работы, что подразумевает возможности запуска приложения в режиме демона, записи данных событий в журнал, использования измененных циклов обработки событий, использования функций профилирования, а также внедрение других возможностей.

Инфраструктура приложения состоит из четырех основных частей: Служб (Services), Приложений (Applications), системы управления конфигурацией (с помощью TAC-файлов и плагинов) и утилиты командной строки twistd. Для иллюстрации этой инфраструктуры мы превратим эхо-сервер из предыдущего раздела в приложение.

Служба

Под службой понимается все то, что может быть запущено и остановлено, а также создается с использованием интерфейса IService. В комплекте поставки Twisted присутствуют реализации служб для TCP, FTP, HTTP, SSH, DNS и многих других протоколов. Множество служб может регистрироваться одним приложением.

Основой интерфейса IService служат следующие методы:

startService Запуск службы. Этот процесс может включать стадии загрузки данных конфигурации, установления соединений с базой данных или начала ожидания соединений на порту.
stopService Остановка службы. Этот процесс может включать стадии сохранения данных на диск, закрытия соединений с базой данных или окончания приема соединений на порту.

Наша эхо-служба использует протокол TCP, поэтому мы можем использовать стандартную реализацию интерфейса IService с названием TCPServer.

Приложение

Приложение является высокоуровневой службой, представляющей всю программу, созданную с использованием Twisted. Службы регистрируют себя в рамках приложений и описанная выше утилита развертывания twistd ищет и запускает приложения.

Мы создадим эхо-приложение, в рамках которого может быть зарегистрирована эхо-служба.

Файлы TAC

При работе с приложениями Twisted, реализованными с использованием обычных файлов исходного кода языка Python, разработчик несет ответственность за разработку кода для запуска и остановки цикла обработки событий, а также настройки приложения. В рамках инфраструктуры приложений Twisted реализации протоколов располагаются в модулях, использующие данные протоколы службы регистрируются с помощью файла конфигурации приложения Twisted (Twisted Application Configuration (TAC)), а цикл обработки событий и процесс конфигурации управляются внешней утилитой.

Для того, чтобы превратить наш эхо-сервер в эхо-приложение, мы можем использовать следующий простой алгоритм:

  1. Перенести части кода эхо-сервера, относящиеся к реализации протокола, в отдельный, предназначенный для них модуль.
  2. В TAC-файле:
    1. Создать эхо-приложение.
    2. Создать экземпляр службы TCPServer, который будет использовать наш класс EchoFactory и зарегистрировать его в рамках приложения.

Код для управления циклом приема событий с использованием утилиты twistd будет обсуждаться ниже. В итоге код приложения будет аналогичен следующему:

Файл echo.py:

from twisted.internet import protocol, reactor

class Echo(protocol.Protocol):
    def dataReceived(self, data):
        self.transport.write(data)

class EchoFactory(protocol.Factory):
    def buildProtocol(self, addr):
    return Echo()

Файл echo_server.tac:

from twisted.application import internet, service
from echo import EchoFactory

application = service.Application("echo")
echoService = internet.TCPServer(8000, EchoFactory())
echoService.setServiceParent(application)

twistd

twistd (произносится "твист-ди") является кроссплатформенной утилитой для развертывания приложений, созданных с использованием Twisted. Она исполняет TAC-файлы и обрабатывает запуск и остановку приложения. Согласно подходу "batteries-included", используемому в ходе разработки сетевого фреймворка Twisted, утилита twistd поддерживает множество полезных параметров конфигурации, в том числе запуск приложения в режиме демона, указание расположения файлов журнала, снижение системных привилегий, запуск приложения в окружении chroot, запуск приложения с нестандартным циклом обработки событий или даже запуск приложения под управлением программы для профилирования.

Мы можем запустить наше эхо-приложение с помощью команды:

twistd -y echo_server.tac

В простейшем случае twistd запустит экземпляр приложения в режиме демона, причем запись сообщений о событиях будет осуществляться в файл с именем twistd.log. После запуска и остановки приложения файл журнала событий будет содержать подобные строки:

2011-11-19 22:23:07-0500 [-] Log opened.
2011-11-19 22:23:07-0500 [-] twistd 11.0.0 (/usr/bin/python 2.7.1) starting up.
2011-11-19 22:23:07-0500 [-] reactor class: twisted.internet.selectreactor.SelectReactor.
2011-11-19 22:23:07-0500 [-] echo.EchoFactory starting on 8000
2011-11-19 22:23:07-0500 [-] Starting factory 
2011-11-19 22:23:20-0500 [-] Received SIGTERM, shutting down.
2011-11-19 22:23:20-0500 [-] (TCP Port 8000 Closed)
2011-11-19 22:23:20-0500 [-] Stopping factory 
2011-11-19 22:23:20-0500 [-] Main loop terminated.
2011-11-19 22:23:20-0500 [-] Server Shut Down.

Использование инфраструктуры приложений Twisted для запуска служб позволяет разработчикам избежать написания шаблонного кода для таких стандартных функций службы, как работа с журналом событий и переход в режим демона. Также она позволяет использовать стандартный интерфейс командной строки для развертывания приложений.

Плагины

Альтернативой системе на основе файлов TAC, предназначенной для запуска приложений Twisted, является система плагинов. В то время, как система на основе файлов TAC упрощает регистрацию простых иерархий заданных в рамках конфигурационного файла приложения служб, система плагинов упрощает регистрацию нестандартных служб, используя для этого подкоманды утилиты twistd, а также расширение интерфейса командной строки приложения.

Особенности использование этой системы:

  1. Требование стабильности предъявляется только к API плагинов, что упрощает процесс усовершенствования сторонними разработчиками программного обеспечения.
  2. В коде предусмотрена возможность поиска плагинов. Плагины могут быть загружены и сохранены при первом запуске программы, повторно найдены при каждом запуске программы или их наличие может проверяться периодически в ходе исполнения программы, что позволяет определять наличие новых плагинов, установленных после запуска программы.

Для расширения возможностей программы с использованием системы плагинов Twisted необходимо просто создать объекты, реализующие интерфейс IPlugin, после чего поместить файл с их объявлениями в определенное место, в котором их будет искать система плагинов.

После преобразования нашего эхо-сервера в приложение Twisted, преобразование его в плагин Twisted будет достаточно простой задачей. К созданному ранее модулю echo, содержащему описание протокола Echo и объявления EchoFactory, мы добавим директорию с именем twisted, содержащую поддиректорию с именем plugins, которая в свою очередь будет содержать объявления классов для нашего эхо-плагина. Этот плагин позволит нам запустить эхо-сервер и указать используемый порт с помощью аргументов утилиты twistd:

from zope.interface import implements

from twisted.python import usage
from twisted.plugin import IPlugin
from twisted.application.service import IServiceMaker
from twisted.application import internet

from echo import EchoFactory

class Options(usage.Options):
    optParameters = [["port", "p", 8000, "Номер порта для приема соединений."]]

class EchoServiceMaker(object):
    implements(IServiceMaker, IPlugin)
    tapname = "echo"
    description = "Эхо-сервер на основе протокола TCP."
    options = Options

    def makeService(self, options):
        """
        Создается объект TCPServer с помощью фабрики из модуля проекта.
        """
        return internet.TCPServer(int(options["port"]), EchoFactory())

serviceMaker = EchoServiceMaker()

Наш эхо-сервер сейчас будет представлен параметром сервера в выводе команды twistd --help и команда twistd echo --port=1235 позволит запустить эхо-сервер на порту 1235.

В составе Twisted имеется модульная система аутентификации для серверов с названием twisted.cred и система плагинов обычно используются для добавления шаблона аутентификации в приложение. Возможно использование AuthOptionMixed из состава twisted.cred для добавления поддержки стандартных систем аутентификации в приложение для командной строки или для добавления нового типа аутентификации. Например, с помощью системы плагинов может быть добавлена возможность аутентификации с помощью стандартной базы данных паролей Unix или сервера LDAP.

В комплекте поставки twistd находятся плагины для множества поддерживаемых Twisted протоколов, что превращает работу по созданию сервера в работу по вводу одной команды. Ниже приведено несколько примеров серверов twistd, поставляемых в составе Twisted:

twistd web --port 8080 --path .
Запуск HTTP-сервера на порту 8080 для обслуживания статического и динамического содержимого рабочей директории.
twistd dns -p 5553 --hosts-file=hosts
Запуск DNS-сервера на порту 5553, преобразующего домены из файла с названием hosts, использующего формат файла /etc/hosts.
sudo twistd conch -p tcp:2222
Запуск SSH-сервера на порту 2222. Ключи SSH должны быть установлены отдельно.
twistd mail -E -H localhost -d localhost=emails
Запуск ESMTP POP3-сервера, принимающего почту для локального узла и сохраняющего ее в директории emails.

Утилита twistd упрощает создание сервера для тестирования клиентов, при этом используя расширяемый код промышленного уровня.

В этом отношении механизмы развертывания приложений Twisted на основе TAC-файлов, плагинов и утилиты twistd были успешны. Однако, сложилась анекдотичная ситуация, при которой большинство крупномасштабных внедрений фреймворка Twisted заканчивалось необходимостью переработки этих систем управления и мониторинга; их архитектура не соответствовала требованиям системных администраторов. Это обстоятельство было обусловлено фактом отсутствия влияния системных администраторов - людей, которые являются экспертами в области внедрения и сопровождения приложений - на архитектуру в ходе истории развития проекта.

Для проекта Twisted в будущем будет полезно более активное взаимодействие с квалифицированными конечными пользователями при принятии новых архитектурных решений в этой области.

21.3. Взгляд в прошлое и выученные уроки

Проект Twisted недавно отметил свой десятый день рождения. С начала его создания под впечатлением от процесса разработки сетевой игры в начале 2000 годов, он в значительной степени достиг цели, заключающейся создании расширяемого, кроссплатформенного управляемого событиями сетевого фреймворка. Twisted используется в промышленных окружениях компаний от Google и Lucasfilm до Justin.TV и платформы для совместной разработки программного обеспечения Launchpad. Реализации серверов Twisted являются основой множества других приложений с открытым исходным кодом, среди которых BuildBot, BitTorrent и Tahoe-LAFS.

Проект Twisted претерпел несколько кардинальных архитектурных изменений с момента начала его разработки. Одним из важнейших нововведений являлась реализация класса Deferred, предназначенного, как было описано выше, для управления ожидаемыми результатами и соответствующими им цепочками функций обратного вызова.

Также было одно важное удаление системы, которое практически не оставило следа в современной реализации: речь идет о системе постоянного хранения данных приложения Twisted.

Система постоянного хранения данных приложения Twisted

Система постоянного хранения данных приложения Twisted (Twisted Application Persistence (TAP)) предназначалась для сохранения данных конфигурации и состояния приложения в файле, формирование которого происходило с использованием модуля pickles из состава Python. Процесс запуска приложения при использовании этой системы состоял из двух стадий:

  1. Создание представляющего приложение файла с помощью на данный момент не используемой утилиты mktap.
  2. Распаковка данных приложения с помощью утилиты twistd.

В основу этого процесса были положены принципы, используемые в образах Smalltalk и позволяющие уйти от ограничений специальных языков описания параметров конфигурации, которые не могли использоваться как сценарии, а применение этих принципов было продиктовано желанием описать параметры конфигурации с помощью кода на языке Python.

Использование этих TAP-файлов сразу же привело к излишним сложностям. Классы фреймворка Twisted могли меняться без изменения экземпляров этих классов, упакованных в файлы. Попытки использования методов или атрибутов классов из новой версии Twisted совместно с упакованными в файл объектами приводили к краху приложений. Было введено понятие "систем обновления", которые должны были обновлять содержимое файлов при изменении версии API, но после этого возникла необходимость поддержки в актуальном состоянии матрицы из систем обновления, версий TAC-файлов и модульных тестов для учета всех возможностей обновления, хотя всесторонний учет всех изменений интерфейса был так же сложен и приводил к ошибкам.

Файлы TAP и соответствующие утилиты были лишены поддержки и в конечном итоге удалены из комплекта поставки Twisted и заменены на TAC-файлы и плагины. Аббревиатура TAC стала расшифровываться как Twisted Application Plugin (плагин приложения Twisted) и на сегодняшний день о неудавшейся системе хранения данных в Twisted напоминает только несколько фрагментов кода.

Урок, выученный в результате фиаско с системой TAP заключается в том, что для возможности осуществления разумной поддержки система хранения данных должна использовать явно установленную схему. В более общем случае это был урок о том, как повышать сложность проекта: при размышлениях о создании инновационной системы для решения определенной задачи перед добавлением кода в проект следует удостовериться в том, что сложность рассматриваемого решения понятна и проверена, а также в том, что достоинства системы явно стоят того, чтобы пойти на усложнение проекта.

web2: урок о повторной разработке

Не являясь архитектурным решением, принятое разработчиками проекта решение о повторной реализации подсистемы Twisted Web длительное время оказывало воздействие на имидж проекта Twisted и на возможность улучшения архитектуры других частей кодовой базы сопровождающими проект людьми, поэтому оно заслуживает краткого описания.

В середине 2000 годов разработчики Twisted решили полностью переработать API twisted.web в рамках отдельного проекта на основе кодовой базы Twisted с названием web2. Проект web2 должен был содержать множество улучшений по сравнению с twisted.web, среди которых полная поддержка протокола HTTP 1.1 и API для работы с потоковыми данными.

Проект web2 считался экспериментальным, но в итоге начал использоваться основными проектами и даже был выпущен и упакован для дистрибутива Debian. Параллельная разработка web и web2 продолжалась в течение нескольких лет и новые пользователи не понимали того, какой проект стоит использовать из-за существования двух аналогичных проектов и отсутствия понятных рекомендаций на этот счет. Перехода на использование проекта web2 так никогда и не произошло, а в 2011 году проект web2 был окончательно удален из кодовой базы проекта и с вебсайта. Некоторые улучшения из проекта web2 медленно портируются в проект web.

Частично по причине событий вокруг проекта web2, проект Twisted заработал репутацию сложно структурируемого и не понятного для новичков проекта. Спустя годы сообщество разработчиков Twisted все еще занимается разрушением этого стереотипа.

Выученный с помощью проекта web2 урок заключается в том, что полная повторная разработка проекта обычно является плохой идеей, но если это происходит, следует удостовериться в том, что сообществу разработчиков понятен долговременный план разработки, а сообщество пользователей имеет один явный выбор реализации для использования во время разработки нового проекта.

В том случае, если в рамках проекта Twisted будет предпринят возврат к прошлому и снова начата разработка web2, разработчикам придется внести множество обратно совместимых изменений и объявить ряд функций устаревшими в рамках twisted.web вместо полной повторной разработки проекта.

Соответствие тенденциям развития Интернет

Методы использования нами сети Интернет продолжают развиваться. Решение о реализации множества протоколов в рамках основной кодовой базы Twisted привело к необходимости поддержки кода для этих протоколов. Реализации приходилось развивать в соответствии с изменениями стандартов и введением новых протоколов, при этом придерживаясь строгой политики обратной совместимости.

Проект Twisted в первую очередь развивается силами добровольных разработчиков и ограничивающим разработку фактором является не энтузиазм сообщества, а свободное время разработчиков. Например, спецификация RFC 2616, описывающая протокол HTTP 1.1, была выпущена в 1999 году, а работа по добавлению поддержки протокола HTTP 1.1 в набор реализаций протоколов Twisted началась в 2005 и закончилась в 2009 году. Поддержка протокола IPv6, описанного в спецификации RFC 2460 от 1998 года, находится в стадии реализации, но все еще не добавлена в основную кодовую базу по данным на 2011 год.

Реализации также приходится развивать в ходе изменения интерфейсов поддерживаемых операционных систем. Например, возможность получения уведомлений о событиях с использованием вызова epoll была добавлена в Linux 2.5.44 в 2002 году и, для того, чтобы использовать преимущества нового API, в Twisted был добавлен цикл обработки событий на основе epoll. В 2007 году компания Apple выпустила версию 10.5 своей операционной системы с именем Leopard, реализация вызова poll в которой не поддерживала работу с устройствами, причем этого изменения было достаточно для нарушения работы и блокировки компанией Apple интерфейса select.poll в своей сборке интерпретатора Python. Проекту Twisted пришлось предложить обходное решение этой проблемы и включить его описание в документацию для пользователей.

Иногда темп разработки Twisted оказывается недостаточным для реализации изменений сетевых протоколов и улучшения перемещаются в библиотеки, не относящиеся к основному коду проекта. Например, проект Wokkel, являющийся коллекцией улучшений поддержки протокола Jabber/XMPP фреймворком Twisted, развивался как предназначенный для слияния с проектом Twisted обособленный проект в течение многих лет без перспектив этого слияния. Ввиду того, что в браузеры начали добавлять поддержку нового протокола WebSockets, в 2009 году была предпринята попытка добавления поддержки этого протокола в Twisted, но разработка все же была перенесена в рамки отдельных проектов после решения разработчиков не включать в Twisted код поддержки протокола до того момента, как на основе черновика IETF не будет сформирован стандарт.

Как было сказано, возможность распространения библиотек и дополнений является свидетельством гибкости и расширяемости фреймворка Twisted. Строгая политика разработки с обязательным тестированием, наличие сопроводительной документации и стандартов разработки кода помогают избегать регрессий в рамках проекта и поддерживать обратную совместимость при наличии поддержки большого количества протоколов и платформ. Это зрелый, стабильный проект, который продолжает активно разрабатываться и внедряться.

Twisted может быть и вашим фреймворком для работы с Интернет в течение следующих десяти лет.

22.1. Сравнение с другими фреймворками

В общих чертах, Yesod больше похож на ведущие фреймворки, такие как Rails и Django., а не отличается от них. Он, в целом, соответствует парадигме Model-View-Controller (MVC), имеет систему шаблонов, в которой внешнее представление отделено от логики, предложена система объектно-реляционного отношения (Object-Relational Mapping — ORM) и есть контролер, предназначенный для реализации навигации.

Дьявол кроется в деталях. В Yesod делается попытка выявить основное количество ошибок на фазе компиляции, а не во время выполнения, а также для автоматически отлавливать как ошибки, так и изъяны в безопасности через систему типов. Хотя Yesod пытается оставаться дружественным высокоуровневым API, в нем для достижения высокой производительности используется ряд новых методов из мира функционального программирования, и эти внутренние особенности не скрыты от разработчиков.

Основной архитектурной проблемой в Yesod является балансирование этих двух, казалось бы противоречащих друг другу целей. Например, нет ничего революционного подхода в методе маршрутизации (используются типобезопасные адреса URL), применяемом в Yesod. Исторически, внедрение такого решения было утомительным процессом, подверженным ошибкам. Инновация, имеющаяся Yesod, состоит в в использовании шаблона Template Haskell(вариант генерации кода) для автоматизации типовых операций, необходимых для начальной самозагрузки процесса. Аналогичным образом, в течение долгого времени были повсюду типобезопасные страницы HTML; Yesod пытается выделить те аспекты, которые дружественны для разработчика и присутствуют в обычных языках работы с шаблонами, и, при этом, сохранить всю мощь, обеспечиваемую безопасностью типов.

22.2. Интерфейс веб-приложений

Веб-приложениям необходим некоторый способ общения с сервером. Одним из возможных способов является встраивание сервера непосредственно во фреймворк, но такой подход обязательно будет ограничивать ваши возможности по развертыванию веб-приложения и ведет к созданию плохих интерфейсов. Для решения этой проблемы во многих языках создавались стандартные интерфейсы: в языке Python есть WSGI, а в Ruby есть Rack. В Haskell, у нас есть WAI: Web Application Interface - веб-интерфейс приложений.

Интерфейс WAI не предназначен для использования в качестве интерфейса высокого уровня. У него есть две конкретные цели: обеспечить единообразность и производительность. Оставаясь единообразным, интерфейс WAI поддерживает работу с различными фоновыми процессами начиная от автономно работающих серверов и до технологии CGI в старом стиле, и даже до непосредственного использования Webkit при создании приложений, работающих на рабочем столе. Что касается производительности, то мы представим вам ряд интересных функций языка Haskell.

Рис.22.1: Общая структура приложения Yesod

Типы данных

Одно из самых больших преимуществ языка Haskell и то, что мы пытаемся максимально использовать в Yesod, это строгая статическая типизация. Перед тем, как начать писать код для того, чтобы что-нибудь решить, нам нужно будет подумать о том, как будут выглядеть данные. Интерфейс WAI является прекрасным примером этой парадигмы. Основная концепция, которую мы хотим выразить, является приложение. Самым основным базовым выражением является функция, к которой делается запрос и от которой возвращается ответ. На языке Haskell:

type Application = Request -> Response

Только возникает вопрос: как должен выглядеть запрос Request и ответ Response? В запросе есть ряд информационных фрагментов, но самым основным являются запрашиваемый путь, строка запроса, заголовки запроса и тело запроса. А в ответе должны быть только три компонента: код состояния, заголовки ответа и тело ответа.

Как мы представляем себе что-то, похожее на строку запроса? В языке Haskell соблюдается строгое разделение между двоичными и текстовыми данными. Первые можно представить с помощью ByteString, а вторые — с помощью Text. Оба типа являются хорошо оптимизированными типами данных, для которых предоставляется высокоуровневый безопасный интерфейс API. В случае строки запроса мы сохраняем необработанные байт данных, передаваемые по сети в виде типа ByteString, а проанализированные декодированные значения — в виде типа Text.

Потоки

Тип ByteString представляет собой отдельно взятый буфер памяти. Если бы мы по наивности использовали простой тип ByteString для передачи тела запроса и получения тела ответа, то наши приложения никогда нельзя было бы масштабировать для работы с большими запросами и ответами. Вместо этого мы используем технологию, называемую enumerator (перечисление), очень похожую по концепции на generators (генераторы) в языке Python. Наше приложение Application становится потребителем потока объектов типа ByteStrings, представляющих собой тело входящего запроса, и создает отдельный поток для ответа.

Теперь нам нужно немного пересмотреть наше определение приложения Application. Приложение Application выдает значение типа Request, в котором содержатся заголовки, строка запроса и т.д., и будет получать поток объектов типа ByteString, из которых состоит ответ Response. Таким образом, пересмотренное определение приложения будет Application следующим:

type Application = Request -> Iteratee ByteString IO Response

Обозначение IO просто указывает какие типы побочных эффектов приложение может выполнять. В случае IO, оно может выполнять любые виды взаимодействий с внешним миром, что является очевидной необходимостью для подавляющего большинства веб-приложений.

Сборщик

Весь фокус нашего арсенала состоит в том, как мы создаем буферы с нашими ответами. У нас есть здесь два конкурирующих пожелания: минимизация количества системных вызовов и минимизация количества копирований буфера. С одной стороны, мы хотим свести к минимуму количество системных вызовов при передаче данных через сокет. Для этого нам нужно хранить исходящие данные в буфере. Тем не менее, если мы сделаем этот буфер слишком большим, то мы исчерпаем нашу память и замедлим время отклика приложения. С другой стороны, мы хотим свести к минимуму количество копирований, когда данные копируются между буферами, предпочтительно копировать только один раз из буфера источника данных в буфер назначения.

В языке Haskell есть решение, которое называется сборщиком (builder). Сборщик представляет собой инструкцию о том, как заполнять буфер памяти, например: разместить пять байтов «hello» в следующей свободной позиции. Вместо передачи потока буферов памяти на сервер, приложение WAI обрабатывает поток этих инструкций. Сервер берет поток и использует его так, чтобы оптимальным образом использовать буфера памяти, имеющие определенный размер. Как только буфер будет заполнен, сервер осуществляет системный вызов с тем, чтобы послать данные по сети, а затем начинает заполнение буфера следующего.

Оптимальный размер буфера будет зависеть от многих факторов, например, от размера кэш-памяти. Библиотека blaze-builder, на основе которой все это выполняется, прошла через существенное тестирование производительности прежде, чем был найден наилучший компромисс.

В теории, такая оптимизация может быть выполнена в самом приложении. Однако, когда этот подход кодируется в интерфейсе, мы можем просто добавлять заголовки ответа в тело ответа. Результатом является то, что для ответов малых и средних размеров, весь ответ может быть отправлен с помощью одного системного вызова и только с одним копированием в память.

Обработчики

Теперь, когда у нас есть приложение, мы должны каким-то образом его запустить. С точки зрения интерфейса WAI, это делает обработчик (handler). В WAI есть некоторые базовые стандартные обработчики, например, автономные серверы Warp (о нем будет рассказано ниже), FastCGI, SCGI и CGI. Такой спектр серверов позволяет запускать приложения WAI приложений, которые будут работать начиная от специально выделенных серверов и до виртуального хостинга. Но, в дополнение к этому, в WAI есть еще несколько интересных возможностей:

Большинство разработчиков, скорее всего, будут использовать Warp. Это достаточно легковесный сервер, которого будет достаточно для тестирования. Он не требует конфигурационных файлов, нет иерархии папок и долгоиграющего процесса, принадлежащего администратору. Это простая библиотека, которая компилируется в вашем приложении или запускается с помощью интерпретатора Haskell. Warp является невероятно быстрым сервером с защитой от всех видов атак, например, Slowloris или бесконечного списка заголовков. Warp может быть единственным веб-сервером, который вам нужен, хотя все будет также в порядке, если его поместить за прокси-сервером HTTP.

С помощью бенчмарка PONG было измерено количество запросов, состоящих из четырехбайтового тела «PONG» и обрабатываемого за секунду на различных серверах. На графике, показанном на рисунке 22.2, Yesod измеряется как фреймворк, работающий поверх Warp. Как видно, сервера Haskell (Warp, Happstack и Snap) лидируют.

Рис.22.2: Бенчмарк PONG для сервера Warp

Большинство причин такой скорости сервера Warp уже были изложены в общей характеристике интерфейса WAI: счетчики, сборщики и упакованные типы данных. Последняя часть головоломки заключается в многопоточности времени выполнения компилятора Glasgow Haskell Compiler (GHC's). GHC, флагманский компилятор языка Haskell, использует легковесные потоки с незначительным потреблением ресурсов. В отличие от системных потоков, можно запустить тысячи таких потоков без серьезной загрузки по производительности. Таким образом, в каждом соединении Warp обрабатывается свой собственный поток со слабым потреблением ресурсов.

Следующая хитрость состоит в асинхронном вводе / выводе. В любом веб-сервере, в котором нужно масштабирование до десятков тысяч запросов в секунду, требуется некоторый тип асинхронной связи. В большинстве языков, это связано с участием сложного в программировании механизма обратного вызова. GHC позволяет нам обмануть систему: мы программируем как будто мы используем синхронный интерфейс API, а GHC автоматически переключается между различными потоками, ожидающими своей очереди.

Глубже GHC пользуется всем, что предоставляет хостовая операционная система, например, kqueue, epoll и select. В результате мы получаем производительность системы ввода / вывода, использующей события, и не беспокоимся о кросс-платформенных вопросах и не пишем в стиле, ориентированном на обратные вызовы.

Промежуточный слой middleware

Между обработчиками и приложениями у нас есть промежуточный слой middleware. Технически он представляет собой преобразователь приложений application transformer: он берет одно приложение и возвращает новое. Это определяется следующим образом:

type Middleware = Application -> Application

Лучший способ понять назначение промежуточного слоя - это рассмотреть некоторые распространенные примеры:

Идея заключается в том, чтобы вынести из приложений за скобки общий код и позволить его использовать совместно нескольким приложениям. Обратите внимание, что, исходя из определения такого промежуточного слоя, мы можем легко расширять такие возможности. Общая схема работы промежуточного слоя состоит в следующем:

  1. Берем значение запроса и применяем некоторые модификации.
  2. Передаем модифицированный запрос в приложение и принимаем ответ.
  3. Модифицируем ответ и возвращаем его обработчику.

В случае, если промежуточных слоев будет несколько, то вместо того, чтобы передавать данные в приложение или в обработчик, можно будет передавать их в промежуточный слой, лежащий глубже, или во внешний слой, лежащий, соответственно, выше.

Тесты wai-test

Никакая статическая типизация не позволит устранить необходимость тестирования. Мы все знаем, что автоматизированное тестирование является необходимостью для любого серьезного приложения. wai-test является рекомендуемым подходом к тестированию приложений с интерфейсом WAI. Поскольку запросы и ответы являются простыми типами данных, легко сформировать искусственный запрос, передать его в приложение и проверить, какой будет ответ. В wai-test просто предлагаются некоторые удобные функции для тестирования общих свойств, таких как наличие заголовка или кода состояния.

22.3. Шаблоны

В типичной парадигме Model-View-Controller (MVC), одной из целей является отделение логики обработки данных от представления данных. Часть этого разделения достигается за счет использования шаблонов языка. Однако, есть много разных способов решения этого вопроса. На одном конце спектра, например, в PHP / ASP / JSP вам разрешается вставлять в ваш шаблон произвольный код. На другом конце, у вас есть системы, такие как StringTemplate и QuickSilver, в которые передаются некоторые аргументы и нет никакого другого способа взаимодействия с остальной частью программы.

В каждой системе есть свои плюсы и минусы. Когда есть более мощная система шаблонов, то это может быть очень удобно. Нужно показать содержимое таблицы базы данных? Нет проблем, вытащите ее с помощью шаблона. Тем не менее, такой подход может быстро привести к запутанному коду, в котором обновления курсора базы данных перемежается с генерацией кода я с HTML. Это можно часто видеть в плохо написанных проектах ASP.

Хотя слабые системы шаблонов упрощают кодирование, они также заставляют делать очень много лишней работы. Часто вам будет нужно не только сохранить ваши исходные значения в типах данных, но и создавать словари значений, которые будут передаваться в шаблон. Поддержка такого кода является непростой задачей и, как правило, у компилятора нет никакого способа помочь вам.

Семейство Yesod языков шаблонов, Shakespearean (Шекспировских) языков, стремится к золотой середине. Используя стандартную в языке Haskell прозрачность ссылок, мы можем быть уверены, что наши шаблоны не дают побочных эффектов. Тем не менее, у них у всех все еще есть полный доступ ко всем переменным и функциям, доступным в коде Haskell. Кроме того, поскольку они во время компиляции полностью проверяются как на корректность и диапазон значений, так и на безопасность типов, вероятность наличия ошибок из-за опечаток гораздо менее вероятна, чем когда вы просто просматриваете код с целью выявить ошибки.

Почему Shakespeare (Шекспир)?

Язык HTML, Hamlet (Гамлет), был первым написанным языком, синтаксис которого базировался на языке Haml. Поскольку это было во времена «сокращенного варианта» Haml, язык Hamlet казался более подходящим. Когда мы добавили CSS и Javascript, мы решили продолжить тему именования и назвали варианты Cassius (Кассий) и Julius (Юлий). На данный момент, язык Hamlet не похож Haml, но, тем не менее, название прижилось.

Типы

Одной из важнейших тем в Yesod является правильное использование типов, что позволяет сделать жизнь разработчиков проще. В шаблонах Yesod у нас есть два основных примера:

  1. Все содержимое, внедренное в шаблон Hamlet (Гамлета), должно иметь тип Html. Как мы увидим позже, это заставляет нас, когда это необходимо, избегать использовать опасный код HTML, например, случайного двойного преобразования.
  2. Вместо того, чтобы вставлять адрес URL непосредственно в наш шаблон, у нас есть типы данных, известные как типобезопасные адреса, которые представляют собой маршруты в нашем приложении.

В качестве реального примера, предположим, что пользователь отправляет в приложение с помощью формы свое имя. Эти данные будут представлены типом данных Text. Теперь мы хотели бы отобразить на странице эту переменную, которая называется name. Система типов во время компиляции предотвращает просто вставить ее в шаблон Hamlet, поскольку она не относится к типу Html. Вместо этого мы должны ее как-то преобразовать. Для этого есть две функции преобразования:

  1. Преобразование toHtml, которое автоматически выбрасывает любые лишние вставки. Таким образом, если пользователь отправляет строку <script src="http://example.com/evil.js"></script>, символы «меньше чем» будут автоматически преобразованы в &lt;
  2. Преобразование preEscapedText, с другой стороны, оставит содержимое в том виде, как оно есть.

Таким образом, в случае недопустимого ввода данных, сделанного, возможно, зловредным пользователем, в качестве рекомендуемого нами подхода будет использование преобразования toHtml. С другой стороны, скажем, у нас есть несколько статических документов HTML, хранящихся на нашем сервере, которые мы хотели бы вставлять в некоторые страницы без всяких изменений. В этом случае, мы могли бы загрузить их в значение Text, а затем применить преобразование preEscapedText, что позволит нам избежать всяких преобразований.

По умолчанию, Hamlet будет использовать функцию toHtml для любого контента, который вы пытаетесь вставить. Таким образом, вам нужно явно выполнить преобразование только в случае, если преобразование вам явно не нужно. Это следует изречению о том, что ошибиться лучше в сторону излишней осторожности.

name <- runInputPost $ ireq textField "name"
snippet <- readFile "mysnippet.html"
return [hamlet|
    <p>Welcome #{name}, you are on my site!
    <div .copyright>#{preEscapedText snippet}
|]

Первым шагом в типобезопасных адресах URL является создание тип данных, который представляет все маршруты на вашем сайте. Скажем, у вас есть сайт для отображения чисел Фибоначчи. Сайт будет иметь отдельную страницу для каждого числа в последовательности и, плюс, домашнюю страницу. Это может быть сделано с помощью типа данных в языке Haskell следующим образом:

data FibRoute = Home | Fib Int

Затем мы могли бы создать страницу, например, следующим образом:

<p>You are currently viewing number #{show index} in the sequence. Its value is #{fib index}.
<p>
    <a href=@{Fib (index + 1)}>Next number
<p>
    <a href=@{Home}>Homepage

Тогда все, что нам нужно, это некоторая функция, которая преобразует типобезопасный URL в строковое представление. В нашем случае, это может выглядеть примерно так:

render :: FibRoute -> Text
render Home = "/home"
render (Fib i) = "/fib/" ++ show i

К счастью для разработчика , все шаблоны, определяющие и отображающие типы данных для типобезопасных адресов URL, обрабатывается в Yesod автоматически. Позже мы рассмотрим это более подробно.

Другие языки

В дополнение к языку Hamlet предлагаются еще три других языка: Julius (Юлий), Cassius (Кассий) и Lucius (Луций). Julius используется для Javascript, однако, это простой сквозной язык, который просто позволяет делать вставки. Другими словами, за исключением случайного использования синтаксиса вставок, любая часть Javascript может быть убрана из Julius и синтаксис останется действительным. Например, для тестирования производительности Julius, с помощью него был добавлен фреймворк Jquery и при этом не возникло никаких проблем.

Два других языка являются альтернативой синтаксису CSS. Те, кто знаком с разницей между Sass и Less увидят это различие немедленно: в Cassius в качестве разделителей используются пробелы, в то время как в Lucius использует фигурные скобки. Lucius, на самом деле, является расширением CSS, то есть все допустимые файлы CSS также будут допустимыми в Lucius. В дополнение к возможности вставки текста, есть некоторые вспомогательные типы данных, предлагаемые для моделирования размеров и цвета. Также в этих языках работают типобезопасные адреса URL, что удобно при определении фоновых изображений.

Помимо безопасности типов и проверок во время компиляции, о чем упоминалось выше, наличие специализированных языков для CSS и Javascript предоставляет нам несколько других преимуществ:

22.4. Хранение данных с помощью Persistent

Большинство веб-приложений будут сохранять информацию в базе данных. Традиционно, это означает некоторый вариант SQL - базы данных. В этой связи, в Yesod продолжается давняя традиция с PostgreSQL в качестве нашего наиболее часто используемого хранилища данных. Но, как мы видели в последнее время, вопрос долговременного хранения данных не всегда решается с помощью SQL-базы. Поэтому проект Yesod был разработан таким образом, чтобы он мог также хорошо работать с базами данных вида NoSQL, и поставляется с MongoDB в качестве отличного решения в качестве хранилища данных.

Результатом этого дизайнерского решения является Persistent, компонент хранения данных, используемый в Yesod. Есть, в действительности, два стиля использования Persistent: сделать его настолько нейтральным к хранимым типам, насколько это возможно, и предложить пользователю полную проверку типов.

В то же самое время, мы в полной мере осознаем, что невозможно полностью оградить пользователей от всех деталей, связанных с хранением данных. Поэтому мы предлагаем два варианта обходных путей:

Терминология

Самым примитивным типом данных в компоненте Persisten является PersistValue. С его помощью представлены все данные, непосредственно хранящиеся в базе данных, например, числа, даты или строки. Конечно, иногда у вас будут некоторые более дружественные типы данных, которые вы захотите хранить, например, HTML. Для этого у нас есть класс PersistField. Внутри базы данных PersistField выражается через PersistValue.

Все это очень хорошо, но мы хотим объединять различные поля вместе в одну большую картину. Для этого у нас есть класс PersistEntity, который является, по существу, коллекцией классов PersistField. И, наконец, у нас есть класс PersistBackend, в котором описывается, как создавать, читать, обновлять и удалять все эти сущности.

В качестве практического примера рассмотрим хранение в базе данных информации о некоторой персоне. Мы хотим хранить имя, день рождения и изображение профиля (файл PNG). Мы создаем новую сущность Person с тремя полями: Text, Day и PNG. Каждый из них хранится в базе данных с использованием другого конструктора PersistValue: PersistText, PersistDay и PersistByteString, соответственно.

Что касается первых двух отображений, то в них нет ничего удивительного, но последнее из них является интересным. Нет конкретного конструктора для хранения содержимого PNG в базе данных, поэтому вместо этого мы пользуемся более универсальным типом (ByteString, который всего лишь является последовательностью байтов). Мы могли бы использовать тот же самый механизм для хранения других типов произвольных данных.

Распространен лучший практический способ хранения изображений, когда данные хранятся в файловой системе, а в базе данных хранится только путь к изображению. Мы не выступаем против использования такого подхода, а используем хранение изображений в базе данных скорее в качестве иллюстративного примера.

Как все это представлено в базе данных? В качестве примера рассмотрим SQL: сущность Person становится таблицей с тремя колонками (имя, дата рождения и изображение). Каждое поле хранится в виде различных типов SQL: тип Text становится типом VARCHAR, тип Day становится типом Date, а тип PNG становится типом BLOB (или BYTEA).

История для MongoDB очень похожа. Тип Person становится его собственным документом document, а его три поля каждой становится полем field в MongoDB. В MongoDB не нужны типы данных и не нужно создавать схему.

PersistentSQLMongoDB
PersistEntityТаблицаДокумент
PersistFieldСтолбецПоле
PersistValueТип столбцаНет

Безопасность типов

Компонент Persistent обрабатывает все данные, решая все проблемы поиска и доступа так, что они для вас будут незаметны. Как пользователь Persistent, вы получаете возможность полностью игнорировать тот факт, что тип Text становится типом VARCHAR. Вы можете просто объявить типы данных и использовать их.

Каждое взаимодействие с Persistent является строго типизированным. Это позволяет предотвратить случайное запоминание номера в поле даты; компилятор это не допустит. В такой ситуации просто исчезают целые классы тонких ошибок.

Нигде сила строгой типизации не проявляется сильнее, чем при рефакторинге. Скажем, у вас в базе данных хранились значения возраста пользователей, и вы понимаете, что в действительности вам вместо них нужно хранить дни рождения. Вы можете сделать одно изменение строки в файле декларации персон, запустить компиляцию и автоматически найти каждую строчку кода, которую следует обновить.

В большинстве динамически типизированных языком, а также в их веб фреймворках, рекомендуется подход к решению этого вопроса, состоящий в написании юнит-тестов. Если тесты покрывают весь код, то, запустив тесты, вам сразу же станет видно, какой код должен быть обновлен. Это все хорошо и правильно, но это более слабое решение, чем использование настоящих типов данных:

Межбазовый синтаксис

Создать схему SQL, которая работает для нескольких движков SQL, может оказаться достаточно сложным. Как создать схему, которая также будет работать с базой данных non-SQL, например, с MongoDB?

омпонент позволяет определять ваши сущности в синтаксисе высокого уровня и автоматически создавать для вас схему SQL. В случае MongoDB, мы в настоящее время используем подход без использования схемы. Также Persistent обеспечивает, чтобы ваши типы данных в языке Haskell точно соответствовали определениям в базе данных.

Кроме того, когда есть вся эта информация, Persistent может для вас автоматически выполнять более сложные функции, такие как миграция данных.

Миграция

В Persistent не только по мере необходимости создаются файлы схем данных, но и автоматически выполняется, если это возможно, миграция базы данных. Модификация баз данных является одним из менее развитых частей стандарта SQL, и поэтому в каждом движке этот процесс происходит по-разному. В результате в каждом компоненте Persistent определяется свой собственный набор правил миграции данных. В PostgreSQL, в котором есть богатый набор правил ALTER TABLE, мы этим пользуется достаточно широко. Поскольку в SQLite таких функциональных возможностей недостаточно, мы решили создавать временные таблицы и выполнять копирование строк. Подход в MongoDB, в котором отсутствует схема данных, означает, что поддержка миграции не требуется.

Эта возможность специально ограничена с тем, чтобы предотвратить любые потери данных. Нельзя удалять какие-либо столбцы автоматически, вместо этого вы получите сообщение об ошибке, указывающее вам на небезопасные операции, которые необходимы для продолжения действия. Затем вам будет предоставлена возможность либо вручную запустить операцию SQL, которая вам будет предоставлена, либо изменить модель данных с тем, чтобы избежать опасного поведения.

Отношения

Компонент Persistent, по своей природе, не является реляционным, что означает, что в движке не поддержка поддержка реализации отношений. Тем не менее, во многих практических ситуациях нам может потребоваться использовать отношения. В этих случаях разработчикам будет предоставлен к ним полный доступ.

Предположим, что нам теперь с каждым пользователем необходимо хранить список его навыков. Если бы мы писали приложение, специально предназначенное для MongoDB, мы могли бы идти дальше и просто хранить этот список как новое поле в исходной сущности Person. Однако такой подход не будет работать в SQL. В SQL, мы называем такие отношения отношениями типа «один-ко-многим».

Идея состоит в том, чтобы создать ссылку на «одну» сущность (персону) в «множественной» сущности (навыках). Тогда, если мы хотим найти все навыки, которые есть у человека, мы просто находим все навыки, в которых есть ссылка на эту персону. В соответствие с этой ссылкой мы получаем идентификатор ID. Заметим, что, как вы могли бы ожидать, эти идентификаторы являются типобезопасными. Типом данных для идентификатора Person ID является тип PersonId. Таким образом, чтобы добавить наш новый навык, нам нужно в нашем определении сущности просто добавить следующее:

Skill
    person PersonId
    name Text
    description Text
    UniqueSkill person name

Эта концепция типа данных ID используется везде в Persistent и в Yesod. Вы можете на основе ID организовать диспетчеризацию объектов. В таком случае, Yesod автоматически будет искать и преобразовывать маршрут из текстового представления ID в его внутреннее представлению, по ходу дела выявляя синтаксические ошибки. Эти идентификаторы ID используются для выполнения операций поиска и удаления с использованием функций get и delete, а также для получения результата операций вставки и выборки значений с использованием функций insert и selectList.

22.5. Yesod

Если мы посмотрим на типичную парадигму «Модель-Представление-Контроллер» («Model-View-Controller» - MVC), то Persistent является моделью, а Shakespeare является преставлением. Тогда все, что остается Yesod, это выступать в роли контроллера.

Самая основная особенность Yesod это - маршрутизация. Она позволяет иметь декларативный синтаксис и типобезопасную диспетчеризацию обращений. На основе этого в Yesod создано много других возможностей: генерация потокового контента, виджеты, интернационализация, статические файлы, формы и аутентификация. Но основной особенностью, которая добавлена в Yesod, в действительности является маршрутизация.

Это многоуровневый подход облегчает пользователям обмениваться различными компонентами системы. Некоторых не интересует использование Persistent. Для них в ядре системы нет ничего, даже упоминающего о Persistent. Точно также, хотя аутентификация и сохранение файлов со статистикой используются часто, эти функции нужны не каждому.

С другой стороны, многие пользователи хотят пользоваться всеми этими функциями. И делают это, включая все оптимизации, имеющиеся в в Yesod, что не всегда просто. Чтобы упростить процесс, в Yesod также предлагается специальный инструментальный набор, с помощью которого настраивается базовая часть сайта с наиболее часто используемыми возможностями.

Маршруты

Учитывая то, что маршрутизация на самом деле является основной функцией Yesod, давайте начнем с нее. Синтаксис маршрутизации очень прост: шаблон ресурса, имя и методы запросов. Например, простой блог-сайт может выглядеть следующим образом:

/ HomepageR GET
/add-entry AddEntryR GET POST
/entry/#EntryId EntryR GET

В первой строке определена домашняя страница. Здесь говорится - «Я представляю собой корневой каталог домена, мое имя HomepageR и я отвечаю на запросы GET». Завершающий символ «R» в именах ресурсов является просто общепринятым соглашением, в нем не закладывается никакого общего смысла, кроме как просигнализировать разработчику о том, что нечто является маршрутом.

Во второй строке определена страница добавочной записи. На этот раз мы отвечаем на оба запроса GET и POST. Вы удивитесь, почему в Yesod, в отличие от большинства фреймворков, требуется, чтобы вы явно указывали методы ваших запросов. Причина в том, что Yesod старается придерживаться принципов RESTful настолько, насколько это возможно, а запросы GET и POST по смыслу действительно очень различные. Мало того, что вы устанавливаете эти два метода по отдельности, затем вы отдельно определяете для них функции обработчиков. В действительности, это дополнительная функция в Yesod. Если вы хотите, вы можете убрать из списка названия методов и функции ваших обработчиков будут использоваться для всех методов запроса.

Третья строка немного интереснее. После второго «слеша» у на есть #EntryId. Таким образом определяется параметр EntryId. Мы уже ссылались на эту функцию в разделе о компоненте Persistent: Yesod будет автоматически строить компонент, представляющий собой путь к соответствующему значению ID. Предположим, что на серверной стороне у нас используется SQL (о Mongo поговорим позже), и если пользователь сделает запрос /entry/5, то функции обработчика будет вызвана с аргументом EntryId 5. Но если пользователь сделает запрос /entry/some-blog-post, то Yesod вернет значение 404.

Очевидно, что это также возможно в большинстве других веб фреймворков. Например, в подходе, применяемом в Django, можно использовать регулярные выражения для проверки соответствия маршрутов, например, r"/entry/(\d+)". Однако подход, применяемый в Yesod, имеет ряд преимуществ:

Адреса типобезопасных URL

Такой подход к маршрутизации порождает одну из самых мощных функций Yesod: типобезопасные адреса URL. Вместо того, чтобы объединять вместе части текста со ссылками, обозначающими маршрут, каждый маршрут в вашем приложении может быть представлен с помощью значения языка Haskell. Благодаря этому сразу исчезает большое количество ошибок 404 Not Found (404 Не найдено): просто невозможно получить неправильный URL. (Все еще можно сформировать URL, который приведет к ошибке 404, например, из-за ссылки на несуществующее сообщение в блоге. Тем не менее, все адреса будут сформированы правильно).

Так как же это магия работает? В каждом сайте есть тип данных route, и для каждого шаблона ресурсов есть свой собственный конструктор. Для нашего предыдущего примера мы получим что-то вроде следующего:

data MySiteRoute = HomepageR
                 | AddEntryR
                 | EntryR EntryId

Если вы хотите перейти по ссылке на домашнюю странице, вы используете HomepageR. Чтобы разместить ссылку на конкретную запись, вы должны использовать конструктор EntryR с параметром EntryId. Например, чтобы создать новую запись и перейти на нее, вы могли бы написать:

entryId <- insert (Entry "My Entry" "Some content")
redirect RedirectTemporary (EntryR entryId)

Во всех языках Hamlet, Lucius и Julius есть встроенная поддержка таких типобезопасный адресов URL. Внутри шаблона на языке Hamlet вы можете легко создать ссылку на страницу с дополнительной записью:

<a href=@{AddEntryR}>Create a new entry.

Что же самое интересное? Точно также, как и в случае с сущностями Persistent, компилятор обеспечит вам полный порядок. Если вы поменяли какие-либо из ваших маршрутов (например, вы хотите включить в число ваших маршрутов год и месяц), Yesod заставит вас повсюду в коде обновить каждую ссылку.

Обработчики

После того, как вы определите ваши маршруты, вы должны сообщить Yesod, как вы хотите отвечать на запросы. Это то место, где в игру вступают функции - обработчики. Настройка очень проста: для каждого ресурса (например, HomepageR) и метода запроса, создается функция с именем methodResourceR. Для нашего предыдущего примера, нам потребовалось бы четыре функции: getHomepageR, getAddEntryR, postAddEntryR и getEntryR.

Все параметры, полученные из маршрута, передаются в качестве аргументов в функцию - обработчик. В функции getEntryR первый аргумент будет иметь тип EntryId, тогда как во всех остальных функциях никаких аргументов не будет вообще.

Функции - обработчики размещаются внутри монады Handler, в которой поддерживается большое количество возможностей, например, перенаправление запроса, доступ к сессии и выполнение запросов к базе данных. Что касается последней возможности, то типичный способ начать работу с функцией getEntryR будет выглядеть следующим образом:

getEntryR entryId = do
    entry <- runDB $ get404 entryId

Это позволит запустить действие, с помощью которого из базы данных будет получена запись, ассоциированная с данным ID. Если такой записи нет, то будет возвращен ответ 404.

Каждая функция - обработчик вернет некоторое значение, которое должно быть экземпляром типа HasReps. Это еще одна особенность, где свою роль играет RESTful: вместо того, чтобы просто вернуть некоторый фрагмент HTML или некоторый объект JSON, вы можете возвращать значение, которое может представлять собой и то, и другое в зависимости от заголовка запроса HTTP Accept. Другими словами, ресурс в Yesod является специфическим элементом данных, и его можно возвращать в одном из множества представлений.

Виджеты

Предположим, вы хотите добавить навигационную панель на нескольких различных страницах вашего сайта. Эта навигационная панель будет загружать пять самых последних постов блога (хранящихся в вашей базе данных), генерировать HTML, а затем использовать несколько фрагментов CSS и Javascript для соблюдения общего стиля сайта.

Если для того, чтобы собрать эти компоненты вместе, нет высокоуровнего интерфейса, то реализация может быть причиной головной боли. Вы можете добавить CSS к файлу CSS, который используется на всем сайте, но такое добавление вспомогательных деклараций не всегда является тем, что вам необходимо. Все тоже самое относится к Javascript, хотя с ним немного хуже: наличие дополнительного фрагмента Javascript может вызвать проблемы на странице, для работы с которой он не был предназначен. Вы также нарушаете модульность, поскольку вам потребуется генерировать результаты, получаемые из базы данных, с исользованием нескольких обработчиков.

В Yesod, у нас есть очень простое решение: виджеты. Виджет является фрагментом кода, в котором воедино связаны HTML, CSS и Javascript, что позволяет вам добавлять содержимое сразу в заголовок и в тело веб страницы, и вы сможете запустить любой произвольный код, для которого есть обработчик. Например, для то того, чтобы реализовать нашу навигационную панель:

-- Get last five blog posts. The "lift" says to run this code like we're in the handler.
entries <- lift $ runDB $ selectList [] [LimitTo 5, Desc EntryPosted]
toWidget [hamlet|
<ul .navbar>
    $forall entry <- entries
        <li&gtl#{entryTitle entry}
|]
toWidget [lucius| .navbar { color: red } |]
toWidget [julius|alert("Some special Javascript to play with my navbar");|]

Но здесь заложено даже больше, чем делается. Когда вы в Yesod создаете страницу, то стандартный подход состоит в объединении нескольких виджетов в один виджет, в котором содержится весь контент вашей страницы, а затем к нему применяется функция defaultLayout. Эта функция определена для каждого сайта, и представляет собой использование стандартного макета сайта.

сегда есть два подхода, определяющих, куда следует поместить код CSS и Javascript:

  1. Объединить их и поместить их внутри вашего HTML в теги style и script, соответственно.
  2. Поместить их во внешние файлы и обращаться к ним с помощью тегов link и script, соответственно.

Кроме того, размер вашего Javascript может быть автоматически уменьшен до минимума. Второй вариант является более предпочтительным, поскольку он позволяет выполнить несколько дополнительных оптимизаций:

  1. Файлы создаются с именами, создаваемыми с помощью хэш-функции по содержимому файла. Это означает, что вы можете пользоваться ими и в будущем, причем не беспокоясь о том, что пользователи получать устаревший контент.
  2. Ваш JavaScript можно загружать асинхронно.

Второй пункт требует некоторой доработки. Виджеты содержат не только JavaScript в его исходном виде, но в них также есть список Javascript-зависимостей. Например, во многих сайтах есть ссылки на библиотеку JQuery, а затем в них добавляются несколько фрагментов Javascript, в которых эта библиотека используется. Yesod может с помощью yepnope.js автоматически преобразовать все это в асинхронную загрузку.

Другими словами, виджеты позволяют создавать модульный динамически компонуемый код, что в результате ведет к исключительно эффективному представлению ваших статических ресурсов.

Подсайты subsite

Во многих веб-сайты есть общие области функциональных возможностей. Пожалуй, двумя наиболее распространенными примерами этого служат статические файлы и аутентификация. В Yesod, вы можете легко поместить такой код в виде подсайта subsite. Все, что вам нужно сделать, это добавить к вашим маршрутам дополнительную строку. Например, чтобы добавить статический подсайт, вы должны написать:

/static StaticR Static getStatic

Первый аргумент сообщает, откуда начинается подсайт. Для статического подсайта обычно используется /static, но вы можете использовать все, что вы захотите. StaticR является именем маршрута; оно также полностью зависит от вас, но, по соглашению, используется StaticR. Static это имя статического подсайта, это единственное, что вы не можете поменять. getStatic является функцией, которая возвращает настройки статического сайта, например, где расположены статические файлы.

Как и все обработчики, у обработчиков подсайтов также есть доступ к функции defaultLayout. Это означает, что для хорошо продуманного подсайта будет автоматически использоваться внешний дизайн вашего сайта без каких-либо дополнительных вмешательств с вашей стороны.

22.6. Усвоенные уроки

Работа на проектом Yesod принесла очень много пользы. Она дала мне возможность работать над большими системами с различными группами разработчиков. Меня в действительности потрясло то, насколько конечный продукт отличается от того, что я первоначально намеревался сделать. Я начал работу на Yesod, составив список целей. В этом списке осталось совсем немного из тех основных функций, которые мы в настоящее время рекламируем в Yesod, и большая часть этого списка уже не та, что я планировал реализовать. Первый урок:

У вас будет более полное представление о системе, которая вам необходима, только после начала работы на ней. Не привязывайте себя к вашей первоначальной идее.

Поскольку это был мой первый крупный кусок кода на языке Haskell, я во время разработки Yesod узнал много нового о языке. Я уверен, что у многих может возникнуть чувство: «Как же я смог написать код, наподобие этого?». Даже при том, что исходный код был не такого калибра, как код Yesod, который есть у нас на данный момент, он был достаточно добротным с тем, чтобы стать толчком к росту проекта. Второй урок заключается в следующем:

Вас не должно отпугивать то, что у вас якобы нет достаточно мастерства в использовании инструментария, имеющегося у вас есть под рукой. Напишите настолько хороший код, насколько это возможно, а затем улучшайте его.

Одним из самых трудных шагов в разработке Yesod был переход от команды разработчиков, состоящей из одного человека — меня, к сотрудничеству с другими разработчиками. Все просто началось со сбора запросов, помещаемых на GitHub, и, в конце концов, закончилось появлением нескольких разработчиков, сопровождающих основной код. Я создал несколько своих собственных шаблонов разработки, которые нигде не были объяснены или документированы. В результате, участники проекта столкнулись с трудностями, когда захотели попробовать мои последние неописанные изменения. Это стало препятствием для многих других, кто хотел бы принять участие в проекте или протестировать его.

Когда Грег Вебер (Greg Weber) поднялся на борт в качестве еще одного ведущего проекта Yesod, он положил использовать много стандартов кодирования, которых катастрофически не хватало. Что еще усугубляло проблемы, это некоторые трудности, присущие экспериментированию с инструментальным набором разработчика на языке Haskell, а именно наличие большого количества пакетов, которые использовались в Yesod. С тех пор одной из целей всей команды разработчиков Yesod было создание стандартных сценариев и инструментов для автоматизации сборки проекта. Большинство из этих инструментов вернулись на своем пути развития обратно в исходное сообщество любителей языка Haskell. Последний урок состоит в следующем:

Сразу решайте, как сделать так, чтобы ваш проект был доступным для других.

На главную -> MyLDP -> Тематический каталог ->

Проект Yocto

Глава 23 из книги "Архитектура приложений с открытым исходным кодом", том 2.

Оригинал: The Yocto Project
Автор: Elizabeth Flanagan
Дата публикации: 23 Октября 2012 г.
Перевод: А.Панин
Дата перевода: 14 Июля 2013 г.

Creative Commons. Перевод был сделан в соответствие с лицензией Creative Commons. С русским вариантом лицензии можно ознакомиться здесь.

Проект Yocto является проектом с открытым исходным кодом, выступающим в роли отправной точки для разработчиков встраиваемых систем на основе Linux в ходе создания специализированных дистрибутивов программных продуктов вне зависимости от применяемого для этого аппаратного обеспечения. Благодаря спонсорской помощи со стороны организации Linux Foundation, проект Yocto является системой, функционально превосходящей простые системы сборки. Он предоставляет в распоряжение разработчиков инструменты, процессы, шаблоны и методы для быстрого создания и внедрения продуктов в рамках рынка встраиваемых систем. Одним из ключевых компонентов проекта Yocto является система сборки Poky Build system. Так как Poky является большой и сложной системой, мы сфокусируем свое внимание на одном из ее ключевых компонентов с названием BitBake. BitBake является инструментом для сборки, созданным под впечатлением от системы Portage из дистрибутива Gentoo и используемым как проектом Yocto, так и сообществами OpenEmbeeded для работы с метаданными и создания образов Linux-систем на основе их исходного кода.

В 2001 году компания Sharp Corporation представила КПК SL-5000 с названием Zaurus, работающий под управлением дистрибутива Linux для встраиваемых систем с названием Lineo. Спустя некоторое время после представления КПК Zaurus, Chris Larson основал проект OpenZaurus Project, направленный на создание дистрибутива на основе Linux для замены существующего в SharpROM и использующий систему сборки с названием buildroot. После создания проекта сторонние участники начали добавлять поддержку новых пакетов программного обеспечения наряду с дополнительными возможностями сборки дистрибутивов для других устройств незадолго до того, как система сборки проекта OpenZaurus начала давать сбои. В январе 2003 года в сообществе началось обсуждение возможности создания новой системы сборки, поддерживающей модель функционирования сообщества в рамках стандартной системы сборки дистрибутивов на основе Linux для встраиваемых систем. В конечном счете это привело к созданию проекта OpenEmbedded. Chris Larson, Michael Lauer и Holger Shurig начали работу в рамках проекта OpenEmbedded с портирования сотен пакетов проекта OpenZaurus для работы с новой системой сборки.

Благодаря этой работе был создан проект Yocto. В основе проекта лежала система сборки Poky, созданная Richard Purdle. Проект начинал свое существование в виде стабилизированной ветки проекта OpenEmbedded, используя основной набор сотен рецептов проекта OpenEmbedded для сборки под ограниченное количество архитектур. Со временем проекты были объединены в нечто большее, чем система сборки программного обеспечения для встраиваемых систем и сформировали завершенную платформу для разработки программного обеспечения с плагином для среды разработки Eclipse, заменой инструмента fakeroot и возможностью работы с образами на основе QEMU. Примерно в ноябре 2010 года организация Linux Foundation выступила с заявлением о том, что работа над системой будет продолжаться в рамках проекта Yocto, получающего спонсорскую поддержку от Linux Foundation. После этого было установлено соглашение о том, что проекты Yocto и OpenEmbedded будут осуществлять координацию работы над ключевым набором метаданных пакетов, называемым OE-Core, комбинируя лучшие черты систем Poky и OpenEmbedded с увеличивающимися масштабами использования уровней для дополнительных компонентов.

23.1. Введение в систему сборки Poky Build System

Система сборки Poky является основой проекта Yocto. В рамках стандартной конфигурации Poky может предоставлять начальный образ системы, настраиваемый в диапазоне от минимального образа, предоставляющего возможность доступа с использованием командной оболочки, до совместимого со стандартом Linux Standard Base образа, использующего прототип пользовательского интерфейса с названием Sato на основе GNOME Mobile and Embedded (GMAE). При использовании этих основных типов образов, уровни метаданных могут быть добавлены для расширения функций; уровни позволяют создать дополнительный стек программного обеспечения для заданного типа образа, добавить в него пакеты для поддержки аппаратного обеспечения (board support packages - BSP) для беспроблемной работы с дополнительным аппаратным обеспечением или даже создать новый тип образа. Используя версию 1.1 системы Poky с кодовым названием "edison", мы покажем то, как BitBake использует рецепты и файлы конфигурации в ходе генерации образа для встраиваемой системы.

При высокоуровневом анализе видно, что процесс сборки начинается с установки параметров окружения оболочки для последующей сборки. Для выполнения этой операции используются исходные данные из файла oe-init-build-env, который находится в корневой директории дерева исходного кода системы Poky. С помощью этого файла настраивается окружение оболочки, создается начальный изменяемый набор файлов конфигурации и осуществляется взаимодействие с окружением выполнения системы BitBake путем использования файла сценария, позволяющего Poky установить, выполняются ли минимальные системные требования.

Например, одним из наблюдаемых с помощью данного сценария параметров является наличие инструмента Pseudo, являющегося заменой fakeroot, переданной проекту Yocto компанией Wind River Systems. В этот момент сценарий bitbake-core-image-minimal, например, должен иметь возможность создать полнофункциональное окружение для кросскомпиляции, после чего сформировать образ Linux-системы на основе описания образа, соответствующего сценарию core-image-minimal, из исходного кода таким образом, как это описано на уровне метаданных проекта Yocto.

Высокоуровневый обзор процесса выполнения задачи Poky
Рисунок 23.1: Высокоуровневый обзор процесса выполнения задачи Poky

В ходе формирования нашего образа BitBake произведет разбор файлов конфигурации, подключит любые дополнительно заданные уровни, классы, задачи или рецепты и начнет с создания цепочки зависимостей с приоритетами. Этот процесс позволяет создать упорядоченную карту задач с установлением их приоритетов. После этого BitBake будет использовать полученную карту задач для установления того, в каком порядке и какие пакеты должны быть собраны для наиболее оптимального разрешения зависимостей компиляции. Задачи, необходимые большинству других задач, имеют большие приоритеты и, следовательно, начинают работу раньше в ходе процесса сборки. Очередь выполнения задач для нашей сборки создана. BitBake также сохраняет итоги разбора метаданных и в том случае, если при последующих запусках устанавливается факт изменения метаданных, могут быть повторно разобраны только измененные метаданные. Планировщик и система разбора данных из состава BitBake являются одними из наиболее интересных архитектурных решений в рамках BitBake, а некоторые окружающие их решения вместе с их реализацией силами разработчиков BitBake также будут рассмотрены далее.

После этого BitBake осуществляет выполнение задач из цепочки, создавая программные потоки (количество которых ограничивается переменной BB_NUMBER_THREADS в файле conf/local.conf) для выполнения этих задач в предварительно заданном порядке. Выполняемые в ходе процесса сборки пакета задачи могут быть изменены, вставлены в начало или конец очереди с помощью соответствующих рецептов. Основной, стандартный порядок выполнения задач сборки пакета начинается с получения и распаковки исходных кодов пакета, конфигурации и кросскомпиляции распакованного исходного кода. После этого скомпилированный исходный код разделяется на пакеты и над результатами компиляции проводятся различные действия, такие, как сбор отладочной информации для пакета. После этого разделенные пакеты упаковываются в пакеты поддерживаемого формата; поддерживаются форматы пакетов RPM, ipk и deb. После этого Bitbake будет использовать эти пакеты для создания корневой файловой системы.

Концепции системы сборки Poky

Одной из наиболее мощных возможностей системы сборки Poky является то обстоятельство, что каждый аспект процесса сборки контролируется с помощью метаданных. Метаданные могут быть свободно разделены на группы файлов конфигурации или рецептов сборки пакетов. Рецепт сборки является набором неисполняющихся метаданных, используемых системой BitBake для установки значений переменных или указания дополнительных задач, выполняемых в процессе сборки. Рецепт сборки содержит такие поля, как описание рецепта, версия рецепта, лицензия пакета и адрес центрального репозитория исходного кода. Он также может указывать на то, что процесс сборки использует autotools, make, distutils или любой другой процесс сборки и в этом случае базовые функции могут быть установлены с помощью классов, унаследованных от класса уровня OE-Core, описанного в файлах директории ./meta/classes. Дополнительные задачи также могут быть описаны наряду с условиями их выполнения. BitBake также поддерживает директивы _prepend и _append в качестве методов расширения функций задачи путем выполнения инъекции кода с добавлением суффиксов в начало и конец описания задачи.

Конфигурационные файлы могут быть разделены на два типа. Первый тип файлов предназначен для конфигурации системы BitBake и всего процесса сборки, а второй - для конфигурации различных уровней, используемых системой Poky для создания различных типов результирующего образа. Под уровнем понимается любая группа метаданных, позволяющая реализовывать какую-либо дополнительную функцию. Они могут использоваться для создания пакетов поддержи аппаратного обеспечения, предназначенных для новых устройств, создания дополнительных типов образов или добавления в образ дополнительного программного обеспечения, не обрабатываемого с помощью основных уровней. Фактически основной набор метаданных проекта Yocto с названием meta-yocto является уровнем, добавляемым выше уровня метаданных OE-Core с названием meta, с помощью которого добавляется дополнительное программное обеспечение и типы образов к заданным на уровне OE-Core.

Примером использования уровней может служить процесс создания образа для устройства NAS на основе платформы Intel n660 (Crownbay), использующего новый 32-битный ABI x32 для архитектуры x86-64, причем выбранное программное обеспечение для создания пользовательского интерфейса будет добавляться с помощью специального уровня.

Задавшись целью, мы разделим функции образа в соответствии с уровнями. На самом нижнем уровне мы используем уровень создания пакетов для поддержки аппаратного обеспечения с целью добавления в образ программных компонентов, предназначенных для поддержки специфичных для платформы Crownbay функций аппаратного обеспечения, например, видео-драйверов. Так как мы хотим использовать x32, нам придется использовать экспериментальный уровень meta-x32. Функции устройства NAS могут быть добавлены уровнем выше с помощью примерного уровня устройства NAS от проекта Yocto с названием meta-baryon. И наконец, мы используем воображаемый уровень с названием meta-myproject для добавления программного обеспечения и файлов конфигурации с целью создания графического пользовательского интерфейса, предназначенного для управления устройством NAS.

В ходе настройки окружения BitBake некоторые начальные файлы конфигурации генерируются на основе данных из пакета oe-build-init-env. Эти конфигурационные файлы позволяют нам в некоторой степени контролировать то, как и какие файлы генерируются системой Poky. Первым файлом из набора этих конфигурационных файлов является файл bblayers.conf. Этот файл мы будем использовать для добавления дополнительных уровней при сборке нашего примера проекта.

Ниже приведено примерное содержание файла bblayers.conf:

# Значение LAYER_CONF_VERSION повышается при каждом несовместимом
# изменении файла  build/conf/bblayers.conf
LCONF_VERSION = "4"
BBFILES ?= ""
BBLAYERS = " \
/home/eflanagan/poky/meta \
/home/eflanagan/poky/meta-yocto \
/home/eflanagan/poky/meta-intel/crownbay \
/home/eflanagan/poky/meta-x32 \
/home/eflanagan/poky/meta-baryon\
/home/eflanagan/poky/meta-myproject \
"

Файл описания уровней bblayers.conf содержит переменную BBLAYERS, которая используется BitBake для поиска уровней. Для лучшего понимания нам следует также рассмотреть устройство используемых уровней. Используя meta-baryon (git://git.yoctoproject.org/meta-baryon) в качестве примера уровня, поинтересуемся содержимым файла конфигурации уровня. Этот файл, conf/layer.conf, разбирается средствами BitBake после начального разбора файла bblayers.conf. Используя полученную информацию, BitBake добавляет дополнительные рецепты сборки, классы и файлы конфигурации в окружение сборки.

Пример распределения уровней в BitBake
Рисунок 23.2: Пример распределения уровней в BitBake

Ниже приведено содержание файла layer.conf из состава meta-baryon:

# Файл конфигурации уровня meta-baryon
# Copyright 2011 Intel Corporation
# Известна директория конфигурации, объединим путь к ней со значением переменной BBPATH для предпочтительного использования наших версий
BBPATH := "${LAYERDIR}:${BBPATH}"

# Известны директории рецептов recipes-*, добавим пути к ним к значению переменной BBFILES
BBFILES := "${BBFILES} ${LAYERDIR}/recipes-*/*/*.bb ${LAYERDIR}/recipes-*/*/*.bbappend"

BBFILE_COLLECTIONS += "meta-baryon"
BBFILE_PATTERN_meta-baryon := "^${LAYERDIR}/"
BBFILE_PRIORITY_meta-baryon = "7"

Все файлы конфигурации BitBake вносят вклад в процесс генерации хранилища данных BitBake, которое используется в процессе создания очереди выполнения задач. При начале сборки используется класс BitBake с названием BBCooker. Этот класс управляет выполнением задачи сборки путем выполнения действий (baking) в соответствии с рецептами (recipes). Одной из первых задач, выполняемой классом, является попытка загрузки и разбора данных конфигурации. Для информирования системы сборки о том, где она должна искать эти данные конфигурации (и в свою очередь о том, где искать метаданные рецептов), вызывается метод класса parseConfigurationFiles. При наличии нескольких исключений, первым конфигурационным файлом, который разыскивается рассматриваемым классом, является файл bblayers.conf. После того, как заканчивается разбор данного файла, BitBake производит разбор файлов layer.conf для каждого из уровней.

После окончания разбора файлов конфигурации уровней метод parseConfigurationFiles разбирает файл bitbake.conf, главной задачей которого является установка глобальных переменных времени сборки, таких, как переменные, отвечающие за структуру имен директорий для различных директорий корневой файловой системы (rootfs), а также переменные с начальным значением LDFLAGS для использования во время компиляции. Большинство конечных пользователей никогда не прибегнет к манипуляциям с этим файлом, так как практически все необходимые параметры, которые может быть необходимо изменить, будут находиться в рамках контекста рецепта сборки вместо файла для всей системы сборки или могут быть переопределены с помощью конфигурационного файла, такого, как local.conf.

Как только этот файл разобран, BitBake также подключает конфигурационные файлы, которые относятся к каждому из уровней, заданных переменной BBLAYERS, и добавляет найденные в этих файлах переменные в свое хранилище данных.

Ниже приведен фрагмент файла bitbake.conf, иллюстрирующий подключенные файлы конфигурации:

include conf/site.conf
include conf/auto.conf
include conf/local.conf
include conf/build/${BUILD_SYS}.conf
include conf/target/${TARGET_SYS}.conf
include conf/machine/${MACHINE}.conf

Пример рецепта BitBake для утилиты grep:

23.2. Архитектура BitBake

Перед тем, как мы перейдем к подробному рассмотрению части архитектурных решений, примененных в системе BitBake, полезно будет понять то, как BitBake собственно работает. Для того, чтобы в полной мере оценить прогресс развития системы BitBake, мы рассмотрим ее начальную версию, BitBake 1.0. В рамках этого первого релиза BitBake цепочка зависимостей для сборки формировалась на основе зависимостей рецептов. В случае какой-либо ошибки в процессе сборки образа, система BitBake должна была перейти к выполнению следующей задачи и позднее повторить ранее неудачную попытку сборки программного компонента. Очевидно, это означало то, что сборка занимала слишком много времени. Другая особенность работы системы BitBake заключалась в том, что каждая из переменных, используемых рецептами, хранилась в одном очень большом словаре. Учитывая количество рецептов, а также количество переменных и задач, необходимых для завершения сборки образа, можно сделать вывод, что система BitBake 1.0 требовала большого объема памяти для работы. В то время, когда оперативная память была достаточно дорогой и системы работали с меньшим ее объемом, сборки могли заканчиваться неудачами. Ситуация с исчерпанием оперативной памяти (и записью данных в раздел подкачки!) была неприемлема для системы, так как процесс сборки является длительным. В своем первозданном виде система хотя и выполняла поставленные перед ней задачи (иногда), но делала это очень медленно, потребляя чрезмерно большие количества ресурсов. Хуже того, в рамках версии 1.0 системы BitBake не было представлено концепции долговременного кэша данных или разделения данных состояния, а также не было возможности осуществлять инкрементальные сборки, поэтому в случае неудачи при сборке приходилось повторять ее с самого начала.

Краткий обзор различий между актуальной версией 1.13.3 системы BitBake, используемой в рамках системы сборки Poky "edison" и версией 1.0 указывает на появление реализации клиент-серверной архитектуры BitBake, долговременного кэша данных, хранилища данных, а также применение оптимизации путем использования техники копирования при записи данных в хранилище, реализации системы разделения данных состояния и значительных улучшений в алгоритмах формирования цепочек зависимостей для задач и пакетов. Данный эволюционный процесс привел к повышению стабильности, производительности и динамичности функционирования системы. Большая часть этих функций была продиктована необходимостью выполнения более быстрых и надежных процессов сборок с затратами меньшего количества ресурсов. Тремя усовершенствованиями системы BitBake, которые мы будем рассматривать, являются: реализация клиент-серверной архитектуры, оптимизации хранилища данных BitBake и работа по улучшению методов формирования цепочек зависимостей для сборок и задач в рамках BitBake.

Механизм межпроцессного взаимодействия системы BitBake

Так как мы уже достаточно хорошо знакомы с тем, как система сборки Poky использует файлы конфигурации, рецепты и уровни для создания образов встраиваемых систем, мы подготовлены к тому, чтобы взглянуть под капот системы BitBake и изучить метод объединения этих компонентов. Начиная с основного исполняемого файла системы BitBake, bitbake/bin/bake, мы можем приступить к рассмотрению процесса, выполняемого BitBake для настройки необходимой для начала сборки инфраструктуры. Первым интересующим нас элементом является механизм межпроцессного взаимодействия (Interparocess Communications - IPC) из состава BitBake. Изначально в рамках BitBake не было представлено концепции клиент-серверного взаимодействия. Эти функции были добавлены в BitBake спустя некоторое время для запуска множества процессов в рамках сборки, так как изначально система была однопоточной, а также для добавления альтернативных пользовательских возможностей.

Обзор механизма межпроцессного взаимодействия системы BitBake
Рисунок 23.3: Обзор механизма межпроцессного взаимодействия системы BitBake

Все сборки, выполняемые с помощью системы Poky, начинаются с создания экземпляра пользовательского интерфейса. Пользовательский интерфейс предоставляет механизмы для записи данных событий, состояния и прогресса сборки наряду с механизмами приема событий выполнения задач сборки посредством модуля событий BitBake. Используемым по умолчанию пользовательским интерфейсом является интерфейс knotty, представляющий собой интерфейс командной строки системы BitBake. Он назван knotty или "(no)tty" из-за того, что работает как с tty-устройствами, так и с файлами, являясь одним из нескольких поддерживаемых интерфейсов. Одним из дополнительных пользовательских интерфейсов является интерфейс Hob. Hob является графическим интерфейсом для BitBake, напоминающим "BitBake commander". В дополнение к стандартным функциям, которые вы можете обнаружить в пользовательском интерфейсе интерфейсе knotty, Hob (разработанный Joshua Lock) предоставляет возможность модификации конфигурационных файлов, добавления дополнительных уровней и пакетов, а также полномасштабного изменения параметров процесса сборки.

Пользовательские интерфейсы системы BitBake имеют возможность отправлять команды следующему подключенному приложением BitBake модулю, реализующему функции сервера BitBake. Как и в случае с пользовательскими интерфейсами, BitBake также поддерживает множество различных типов серверов, таких, как XMLRPC. Стандартным сервером, который использует большинство пользователей при запуске системы BitBake с интерфейсом knotty, является сервер процесса BitBake. После запуска сервера приложение BitBake подключает модуль сборки.

Модуль сборки является основной частью BitBake, с помощью которой вызывается большая часть наиболее интересных процессов в ходе сборки с использованием системы Poky. Модуль сборки управляет разбором метаданных, инициирует генерацию деревьев зависимостей и задач, а также непосредственно управляет процессом сборки. Одной из функций архитектуры серверной части BitBake является предоставление различных возможностей раскрытия командного API для опосредованного доступа к нему со стороны пользовательского интерфейса. Модуль команд является рабочим элементом системы BitBake, осуществляющим запуск команд сборки и генерирующим события, которые передаются пользовательскому интерфейсу, минуя обработчик событий BitBake. В тот момент, когда приложение BitBake подключает модуль сборки, он инициализирует хранилище данных BitBake, после чего начинает разбор всех конфигурационных файлов системы сборки Poky. После этого модуль создает объект текущей очереди и начинает сборку.

Хранилище данных DataSmart системы BitBake с возможностью копирования при записи

В версии 1.0 системы BitBake переменные после извлечения из файлов помещались в один очень большой словарь в ходе инициализации класса данных. Как было сказано ранее, это приводило к проблемам, так как при работе с очень большими словарями языка Python операции записи и доступа к элементам осуществляются достаточно медленно и если на сборочной машине в процессе сборки закончится физическая память, будет использоваться раздел подкачки. Хотя такой сценарий и маловероятен для большинства систем в конце 2011 года, во время создания проекта OpenEmbedded и системы BitBake среднестатистические характеристики компьютеров обычно включали в себя объем оперативной памяти меньше одного или двух гигабайт.

Эта особенность являлась одним из слабых мест ранних версий системы BitBake. Двумя основными недостатками, которые требовали исправления для повышения производительности, являлись: во-первых, отсутствие возможности предварительного создания цепочки зависимостей сборки; во-вторых, необходимость сокращения объема данных, хранящихся в памяти. Большая часть хранящихся данных рецептов не изменялась от рецепта к рецепту; например, при наличии значений переменных TMPDIR, BB_NUMBER_THREADS и других глобальных переменных BitBake хранение в памяти всех данных окружения для каждого из рецептов являлось неэффективным. Решением проблемы оказался разработанный Tom Ansell словарь с поддержкой метода копирования при записи, который "злоупотреблял классами для того, чтобы быть замечательным и быстрым". Модуль с реализацией метода копирования при записи системы BitBake является одновременно и особо радикальным и разумным решением. Выполнение команды python BitBake/lib/bb/COW.py и исследование модуля облегчат ваше понимание принципа работы реализации механизма копирования при записи и способа использования этой реализации средствами системы BitBake для эффективного хранения данных.

Модуль DataSmart, использующий словарь с поддержкой метода копирования при записи, хранит все данные начальной конфигурации системы Poky, данные из файлов с расширениями .conf и .bbclass в словаре в виде объектов данных. Каждый из этих объектов может содержать другой объект данных с информацией исключительно об отличиях данных. Таким образом, в том случае, если с помощью рецепта происходит изменение данных начальной конфигурации, вместо копирования всей конфигурации с целью локализации, на уровне ниже в стеке данных, подвергающихся копированию при записи, сохраняется объект, содержащий информацию об отличиях данных родительского объекта. При попытке доступа к переменной модуль данных будет использовать модуль DataSmart для объектов на верхнем уровне стека. В том случае, если переменная не обнаруживается, осуществляется переход на нижний уровень стека до тех пор, пока переменная не обнаруживается, либо генерируется ошибка.

Одна из других интересных особенностей модуля DataSmart находится в области раскрытия переменных. Так как переменные BitBake могут содержать исполняемый код на языке Python, одной из необходимых для выполнения операций является передача значения переменной методу bb.codeparser для установления того, что это значение представляет собой корректный код на языке Python и не содержит циклических ссылок. В качестве примера переменной, содержащей код на языке Python, может использоваться фрагмент файла ./meta/conf/distro/include/tclibc-eglibc.inc:

LIBCEXTENSION = "${@[", '-gnu'][(d.getVar('ABIEXTENSION', True) or ") != "]}"

Эта переменная подключается с помощью одного из конфигурационных файлов уровня OE-Core с именем ./meta/conf/distro/include/defaultsetup.conf и используется для формирования набора стандартных параметров в различных конфигурациях дистрибутивов, которые могут использоваться при работе с Poky и OpenEmbedded. Этот файл позволяет импортировать некоторые специфичные для библиотеки eglibc переменные, значения которых устанавливаются в зависимости от значения другой переменной BitBake с именем ABIEXTENSION. В процессе создания хранилища данных код на языке Pyhon из данной переменной должен быть разобран и проверен для того, чтобы избежать неудачного завершения задач, использующих эту переменную.

Планировщик BitBake

После того, как система BitBake произвела разбор файлов конфигурации и создала хранилище данных, ей необходимо произвести разбор рецептов, требующихся для создания образа, и сформировать цепочку сборки. Эта операция является одним из наиболее существенных усовершенствований системы BitBake. Изначально система BitBake получала приоритеты сборки из рецепта. Если в рамках рецепта была заданна переменная DEPENDS, предпринималась попытка установления того, какие программные компоненты следует собрать для того, чтобы выполнить заданные этой переменной требования. В том случае, если выполнение задачи завершалось неудачей из-за того, что для сборки не хватало какого-либо предварительного условия, задача просто убиралась для последующего повторного выполнения. Этот подход имел очевидные недостатки, связанные и с производительностью, и с надежностью.

Так как предварительно формируемой цепочки зависимостей не создавалось, порядок выполнения задач устанавливался непосредственно во время сборки. Это обстоятельство ограничивало возможности системы BitBake однопоточным режимом сборки. Для того, чтобы продемонстрировать, насколько непроизводительной может оказаться сборка образов с помощью BitBake в однопоточном режиме, следует упомянуть о том, что при сборке образа самого малого размера "core-image-minimal" на стандартной машине разработчика в 2011 году (Intel Core i7 с 16 ГБ оперативной памяти DDR3) потребуется около трех или четырех часов для сборки полного набора инструментов кросскомпиляции и применения их для создания пакетов, которые впоследствии будут использоваться для создания образа. Для сравнения, сборка на той же машине с переменными BB_NUMBER_THREADS со значением 14 и PARALLEL_MAKE со значением "-j 12" занимает от 30 до 40 минут. Как можно представить, работа в однопоточном режиме без предварительного формирования последовательности выполнения задач с использованием более медленного аппаратного обеспечения с меньшим объемом оперативной памяти, большое количество которой может быть занято копиями всего хранилища данных, потребует значительного большего времени.

Зависимости

При разговоре о зависимостях сборки нам следует проводить разделение зависимостей различных типов. Зависимости сборки, задаваемые с помощью переменной DEPENDS, являются чем-либо, что нам требуется предварительно предоставить для того, чтобы сборочная система Poky смогла собрать требуемый пакет, в то время, как зависимости времени исполнения, задаваемые с помощью переменной RDEPENDS, требуют от образа установки заданных с помощью переменной RDEPENDS пакетов наряду с запрашиваемым пакетом. Возьмем, например, пакет с названием task-core-boot. Если мы рассмотрим рецепт этого пакета, расположенный по пути

meta/recipes-core/tasks/task-core-boot.bb

мы обнаружим две установленные переменные BitBake: RDEPENDS и DEPENDS. Система BitBake использует эти два поля в процессе создания цепочки зависимостей.

Ниже приведен фрагмент файла task-core-boot.bb, демонстрирующий использование переменных DEPENDS и RDEPENDS:

DEPENDS = "virtual/kernel"
 ...

RDEPENDS_task-core-boot = "\
base-files \
base-passwd \
busybox \
initscripts \
...

Пакеты не являются единственными элементами, зависимости которых отслеживаются средствами BitBake. Задачи также имеют свои зависимости. В рамках очереди выполнения сборки BitBake мы выделяем четыре типа задач: внутренне-зависимые задачи, зависимые на основе значения переменной DEPENDS задачи, зависимые на основе значения переменной RDEPENDS задачи, а также зависимые от задач других пакетов задачи.

Внутренне-зависимые задачи устанавливаются в рамках рецепта и позволяют добавить задачу перед и/или после другой задачи. Например, мы можем добавить задачу с названием do_deploy в рецепт путем добавления строки addtask deploy before do_build after do_compile. Эта строка позволит добавить зависимость для запуска задачи do_deploy перед запуском задачи do_build, но после завершения выполнения задачи do_compile. Зависящие от значений переменных DEPENDS и RDEPENDS задачи являются задачами, выполняющимися после обозначенной задачи. Например, если мы хотим выполнить задачу do_deploy для пакета после выполнения задачи do_install для пакетов, заданных переменными DEPENDS или RDEPENDS, наш рецепт будет включать строку do_deploy[deptask] = 'do_install' или do_deploy[rdeptask] = 'do_install'. В случае задач, зависимых от задач других пакетов, если мы хотим чтобы заданная задача зависела от задачи другого пакета, мы добавим строку, изменив приведенный выше пример использования функции do_deploy следующим образом: do_deploy[depends] = "<название целевого пакета>:do_install".

Очередь выполнения сборки

Так как в процессе сборки образа могут быть задействованы тысячи рецептов, каждый из которых может содержать множество пакетов и задач со своими зависимостями, на данный момент BitBake пытается разобраться в этих зависимостях и сформировать какую-либо структуру, которую можно будет использовать для установления порядка выполнения задач. После того, как модуль сборки получает в процессе инициализации объекта bb.data полный список пакетов, которые необходимо собрать, он приступает к созданию структуры распределения задач с приоритетами на основе этих данных для формирования упорядоченного списка необходимых для выполнения задач под названием "очередь выполнения сборки" (runqueue). Сразу же после формирования очереди выполнения сборки BitBake может начать выполнение находящихся в ней задач с учетом их приоритетов, причем каждая задача будет выполняться в отдельном потоке.

При задействовании модуля установления источника пакета, BitBake в первую очередь проверяет, установлено ли значение переменной PREFERRED_PROVIDER для заданного пакета или образа. В том случае, если более чем один рецепт может предоставить заданный пакет и так как задачи устанавливаются в рамках рецептов, от BitBake необходимо принять решение, какой источник пакета будет использован. Система отсортирует все источники пакета, поставив в соответствие каждому из них приоритет, установленный на основе совокупности различных критериев. Например, предпочтительные версии программного обеспечения будут иметь больший приоритет, чем все остальные. Однако, BitBake также учитывает версию пакета наряду с его зависимостями от других пакетов. После того как выбран рецепт, который будет использован для создания пакета, BitBake последовательно исследует значения переменных DEPENDS и RDEPENDS данного рецепта и перейдет к установлению источников для полученных названий пакетов. В ходе этой цепной реакции формируется список пакетов, необходимых для генерации образа, а также списки источников для данных пакетов.

Теперь очередь выполнения сборки располагает полным списком пакетов, которые должны быть собраны, а также цепочкой зависимостей. Для начала работы модуль выполнения сборки должен создать объект TaskData, таким образом начав сортировку структуры распределения задач на основе приоритетов. Этот процесс начинается с рассмотрения каждого найденного предназначенного для сборки пакета, разделения задач, необходимых для генерации этого пакета и присвоения каждой из этих задач приоритета на основании количества пакетов, требующих ее. Задачи с более высоким приоритетом имеют большее количество зависимостей и, следовательно, в целом выполняются раньше в процессе сборки. После завершения этой работы модуль очереди выполнения сборки подготавливает данные для преобразования объекта TaskData непосредственно в очередь выполнения сборки.

Процесс формирования очереди выполнения сборки отчасти сложен. Вначале BitBake обходит список имен задач объекта TaskData для установления зависимостей задач. Так как выполняется обработка данных объекта TaskData, начинается создание структуры распределения задач с приоритетами. После окончания этого процесса в случае отсутствия циклических зависимостей, задач, выполнение которых невозможно, а также других подобных проблем, распределение задач будет упорядочено на основании приоритетов и модулю выполнения сборки будет возвращен объект с полной очередью выполнения сборки. Модуль выполнения сборки предпримет попытку последовательного выполнения задач из очереди. В зависимости от размера образа и вычислительных ресурсов, системе сборки Poky может потребоваться от получаса до нескольких часов для генерации набора инструментов кросскомпиляции при указании пакета и выборе необходимого образа встраиваемой системы на основе Linux. Стоит отметить, что с момента выполнения команды bitbake <имя_образа> с использованием командной строки, весь процесс, начинающийся с выполнения задач из очереди выполнения сборки, занимает меньше нескольких секунд.

23.3. Заключение

После моих дискуссий с членами сообщества и личных исследований, я установила несколько областей, в которых некоторые вещи, возможно, должны были быть реализованы иначе, а также усвоила несколько ценных уроков. Важно отметить, что оценка десятилетней разработки не участвующим в ней человеком не является критикой тех, кто вложил свое время и усилия в весь этот замечательный набор программного обеспечения. Говоря от лица разработчиков, наиболее сложной частью нашей работы является прогнозирование того, что нам понадобится спустя годы и как мы могли бы спроектировать фреймворк, чтобы эти возможности работали прямо сейчас. Без некоторых проблем это удается только единицам.

Первый усвоенный урок заключается в том, что нужно быть уверенным в необходимости разработки соответствующей стандартом документации с четкими формулировками, которая понятна сообществу. Она должна проектироваться с учетом максимальной гибкости и последующего развития.

Одной из областей, где я лично столкнулась с недоработкой документации является моя работа над классом создания отметки лицензии из состава OE-Core, а в особенности данная недоработка проявилась при работе с переменной LICENSE. Так как не существовало четко документированной стандартизации того, что может содержать переменная LICENSE, обзор множества доступных рецептов показал значительные различия в объявлениях. Различные строки, являющиеся значениями переменной LICENSE, содержали все что угодно, от значений, использующих абстрактно-синтаксисические деревья Python в строковом представлении, до значений, вероятность извлечения полезных данных из которых была крайне мала. Существовало соглашение, которое обычно использовалось в сообществе; однако, это соглашение предполагало различные варианты, некоторые из которых были менее корректными, чем другие. Эта проблема не была вызвана действиями разработчиков, которые создавали рецепт; она была вызвана неспособностью сообщества выработать стандарт.

Хотя небольшая предварительная работа со значением переменной LICENSE помимо проверки ее существования все таки была проведена, о стандартизации значений данной переменной никто не позаботился. Большую часть проблем удалось бы избежать в случае заблаговременной разработки поддерживаемого всеми стандарта в масштабе проекта.

Следующий усвоенный урок является более общим и относится к недоработке, наблюдаемой не только в рамках проекта Yocto, но и в других крупномасштабных проектах, находящихся в зависимости от архитектуры системы. Одна из наиболее важных идей для разработчиков, позволяющая ограничить трудозатраты по копированию, рефакторингу и удалению кода, с необходимостью которых они могут столкнуться при работе над проектом, формулируется следующим образом: тратьте время - много времени - на проектирование интерфейсов и проработку архитектурных решений.

Если вы думаете, что потратили достаточно времени на работу над архитектурой, вероятно, это не так. Если вы думаете, что потратили недостаточно времени на работу над архитектурой, то это несомненно так и есть. Затраты большего количества времени на проектирование интерфейса не усложнят последующие операции по удалению кода или даже реализации значительных архитектурных изменений, но, несомненно, сократят объем повторной разработки в долгосрочной перспективе.

Проектируйте свое программное обеспечение так, чтобы оно было настолько модульным, насколько это возможно, ведь рано или поздно вы будете возвращаться к некоторым участкам кода для проведения любых операций начиная с небольших исправлений и заканчивая повторной разработкой, а когда вам все таки придется работать с кодом, его замена станет менее сложной.

Очевидной областью, в которой такой подход мог помочь проекту Yocto, является установление потребностей конечных пользователей, эксплуатирующих системы с малым объемом оперативной памяти. В том случае, если бы реализация хранилища данных BitBake была заранее более тщательно продумана, возможно, нам удалось бы спрогнозировать вероятность появления проблем, связанных с тем, что хранилище данных занимает большой объем памяти, и заранее доработать его.

Данный урок заключается в том, что хотя и практически невозможно предугадать каждую из проблем, с которой столкнется ваш проект в период своего существования, выделение времени для серьезного планирования его интерфейса может помочь в снижении последующих трудозатрат. Проекты BitBake, OE-Core и Yocto являются удачными в этом плане, так как большое количество работы над их архитектурой было произведено на ранних стадиях развития. Это обстоятельство позволило нам произвести значительные изменения архитектуры проекта без больших сложностей и ущерба проекту.

23.4. Благодарности

Во-первых, благодарю Chris Larson, Michael Lauer и Holger Schurig, а также многих других людей, которые вносили свой вклад в развитие BitBake, OpenEmbedded, OE-Core и проекта Yocto в течение многих лет. Также благодарю Richard Purdie за то, что он предоставил в мое распоряжение свой мозг и помог разобраться как в исторических, так и в технических аспектах OE, а также за его постоянную поддержку и руководство, особенно в случаях исследования некоторых магических аспектов BitBake.

24.1. Приложение или библиотека

ØMQ является библиотекой, а не сервером обмена сообщениями. На это у нас пошло несколько лет работы с протоколом AMQP: на попытку стандартизировать в финансовой индустрии сетевой протокол для обмена бизнес сообщениями, на написание эталонной реализации для него и участие в нескольких крупномасштабных проектах, базирующихся в значительной степени на технологии обмена сообщениями, на понимание того, что что-то не так с классической клиент/серверной моделью умного сервера обмена сообщениями (брокера) и бессловесными клиентами обмена сообщениями.

Наша главная задача в то время была связана с производительностью: Если в середине находится сервер, то каждое сообщение должно пройти в сети два раза (от отправителя к брокеру и от брокера к приемнику), порождая при этом проблемы, связанные с задержками и пропускной способностью. Более того, если все сообщения передаются через брокера, то в какой-то момент он обязан стать узким местом.

Вторая задача была связана с развертыванием системы в крупномасштабных сетях: когда при развертывании пересекаются организационные границы, то концепция центрального органа управления всем потоком сообщений перестает применяться. Ни одна из компаний не готова уступить контроль сервером другой компании, есть коммерческая тайна и есть юридическая ответственность. Результат на практике состоит в том, что в компании есть один сервер обмена сообщениями с рукописными мостами для подключения к системам обмена сообщениями в других компаниях. Поэтому экосистема в целом сильно фрагментирована, а поддержка большого количества мостов для каждой участвующей компании не делает ситуацию лучше. Чтобы решить эту проблему, нам нужна полностью распределенная архитектура, архитектура, в которой каждым компонентом может управлять, возможно, иной хозяйствующий субъект. Учитывая, что блоком управления в серверной архитектуре является сервер, мы можем решить эту проблему путем установки отдельного сервера для каждого компонента. В таком случае можно дополнительно оптимизировать проект, сделав так, сервер и компонент будут использовать одни и те же процессы. Так, что в итоге у нас мы все это завершилось созданием библиотеки обмена сообщениями.

Проект ØMQ был начат, когда мы получили представление о том, как сделать работу с сообщениями без центрального сервера. Это требует переворот всей концепции сообщений с ног на голову и замены модели автономного централизованного хранилища сообщений в центре сети на архитектуру «умные конечные точки, молчащая сеть», которая базируется на принципе соединений «точка-точка». Техническим следствием этого решения было то, что проект ØMQ с самого начала был библиотекой, а не приложением.

Одновременно мы смогли доказать, что такая архитектура является более эффективной (меньшие задержки, более высокая пропускная способность) и более гибкой (она проще для построения различных сложных топологий, чем привязка к классической модели «ось и спицы»).

Однако, одним из непреднамеренных последствий было то, что выбор в пользу библиотечной модели улучшил удобство работы с продуктом. Снова и снова пользователи выражают свое удовольствие по поводу того, что им не требуется устанавливать автономный сервер обмена сообщениями и им управлять. Оказывается, что отсутствие сервера является предпочтительным вариантом, поскольку это сокращает эксплуатационные затраты (не требуется администратор сервера сообщений) и становится проще выход на рынок (нет необходимости вести переговоры о необходимости запуска сервера с клиентом, командой, осуществляющей управление, или эксплуатационной командой).

Усвоенный урок в том, что при запуске нового проекта вы должны отдавать предпочтение созданию библиотек, если это вообще возможно. Довольно легко создать приложение из библиотеки, запустив ее из тривиальной программы, но практически невозможно создать библиотеку из существующих исполняемых модулей. Библиотека для пользователей будет гораздо более гибкой и в то же время не потребует от них нетривиальных административных усилий.

24.2. Глобальное состояние

Глобальные переменные не всегда хороши при использовании с библиотеками. Библиотека может загружаться в процесс неоднократно, но даже в этом случае будет только один набор глобальных переменных. На рис.24.1 показана библиотека ØMQ, которая используется из двух различных и независимых библиотек. Затем приложение использует обе эти библиотеки.

Рис.24.1: ØMQ используется двумя различными библиотеками

Когда возникает такая ситуация, то оба экземпляра ØMQ имеют доступ к одним и тем же переменным, в результате чего возникают состояния гонки, странные сбои и неопределенное поведение.

Чтобы избежать этой проблемы, в библиотеке ØMQ отсутствуют глобальные переменные. Вместо этого пользователь библиотеки ответственен за работу в явном виде с глобальным состоянием. Объект, содержащий глобальные состояние, называется контекстом. Хотя с точки зрения пользователя контекст выглядит более или менее похожим на пул рабочих потоков, с точки зрения ØMQ это просто объект для хранения любого глобального состояния, которое нам понадобится. На рисунке, приведенном выше, библиотека libA должна иметь свой собственный контекст точно также, как и библиотека libB. Тогда ни одна из них не сможет вывести из строя или повлиять на другую библиотеку.

Усвоенный здесь урок довольно очевиден: не используйте в библиотеках глобальное состояние. Если вы это делаете, то в случае, когда в одном и том же процессе будет использовано два экземпляра библиотеки, библиотека выйдет из строя.

24.3. Производительность

Когда проект ØMQ был запущен, его основной целью была оптимизация производительности. Производительность системы обмена сообщениями оценивается с помощью двух метрик: пропускной способности - сколько сообщений может быть передано в течение определенного количества времени, и задержкой - сколько времени требуется сообщению для того, чтобы добраться от одной конечной точки к другой.

На какой показатель мы должны ориентироваться? Какая связь между ними? Разве это не очевидно? Запустите тест, разделите общее время теста на количество пришедших сообщений и вы получаете задержку. Разделите количество сообщений на время и вы получаете пропускную способность. Другими словами, задержка обратно пропорциональна величине пропускной способности. Тривиально, не так ли?

Вместо того, чтобы сразу начать кодирование, мы потратили несколько недель на более подробное исследование метрик производительности и выяснили, что отношения между пропускной способностью и задержкой гораздо более тонкое, чем приведенное выше, и часто метрики довольно нелогичны.

Представьте себе, A отправляет сообщения для B (смотрите рис.24.2.) Общее время теста составляет 6 секунд. Было передано 5 сообщений. Следовательно пропускная способность равна 0,83 сообщения/сек (5/6), а задержка равна 1,2 сек (6/5), не так ли?

Рис.24.2: Отправка сообщений из A в B

Снова взгляните на диаграмму. На ней видно различное время для каждого сообщения, которое поступает из A в B: 2 сек, 2,5 сек, 3 сек, 3,5 сек, 4 сек. В среднем это 3 секунды, что довольно далеко от нашего первоначального расчета в 1,2 секунды. Этот пример показывает заблуждения, которые интуитивно делаются относительно метрик производительности.

Теперь взгляните на пропускную способность. Общее время теста составляет 6 секунд. Однако, в A будет затрачено всего 2 секунды для того, чтобы отправить все сообщения. С точки зрения A пропускная способность равна 2,5 сообщений/сек (5/2). В B затрачивается 4 секунды для того, чтобы принять все сообщения. Так что с точки зрения B пропускная способность равна 1,25 сообщений/сек (5/4). Ни одно из этих значений не соответствует нашему первоначальному расчету в 1,2 сообщений/сек.

Короче говоря, задержка и пропускная способность являются двумя различными метриками - это очевидно. Важно понимать различие между ними и их взаимосвязь. Задержку можно измерить только между двумя различными точками в системе; нет такого понятия, как задержка в точке А. Каждое сообщение имеет свою собственную задержку. Вы можете вычислить среднее значение задержек нескольких сообщений, однако, нет такого понятия, как задержка потока сообщений.

Пропускная способность, с другой стороны, может измеряться только в одной точке системы. Имеется пропускная способность отправителя, пропускная способность принимающей стороны, есть пропускная способность любой промежуточной точкой между ними, но нет такого понятия, как общая пропускная способность всей системы. И пропускная способность имеет смысл только для набора сообщений, нет такого понятия, как пропускная способность одного сообщения.

Что касается отношений между пропускной способности и задержкой, то, оказывается, действительно, между ними есть взаимосвязь; однако, в формуле есть интегралы и мы здесь ее обсуждать не будем. Для получения дополнительной информации, читайте литературу по теории очередей.

При оценке производительности системы обмена сообщениями есть еще очень много подводных камней, так что мы не будем вдаваться в подробности. Упор нужно сделать на следующем усвоенном уроке: Убедитесь, что вы понимаете проблему, которую вы решаете. Даже такая проблема, как просто «сделать что-то более быстрым» может для ее правильного понимания потребовать большого объема работы. Более того, если вы не понимаете проблему, вы, вероятно, на основе неявных предположений и популярных мифов создадите код, в результате чего это решение будет иметь недостатки или, по крайней мере, будет гораздо более сложным или гораздо менее полезным, чем это можно было бы сделать.

24.4. Критический путь

Мы в процессе оптимизации обнаружили , что на производительность оказывают решающее влияние следующие три фактора:

Однако не каждая операция выделения памяти и не каждый системный вызов оказывает одинаковое влияние на производительность. Характеристикой, которая нас интересует в системах обмена сообщениями, является количество сообщений, которое мы можем передать между двумя конечными точками в течение определенного количества времени. Кроме того, нас может интересовать то, как долго сообщение передается из одной точки в другую.

Но, учитывая то, что ØMQ предназначена для сценариев с долгоживущими соединениями, время, необходимое для установления соединения или время, необходимое для обработки ошибки соединения, в основном, значения не имеет. Эти события происходят очень редко, и поэтому их влияние на общую производительность незначительно.

Та часть кода, которая снова и снова используется очень часто, называется критическим путем (critical path); оптимизировать следует критический путь.

Давайте рассмотрим пример: библиотека ØMQ не очень оптимизирована относительно выделения памяти. Например, при обработке строк, она часто выделяет новую строку для каждого промежуточного этапа преобразования. Тем не менее, если мы посмотрим строго на критический путь, т. е. фактическую передачу сообщений, мы увидим, что в библиотеке практически не происходит выделение памяти. Если сообщения небольшие, то это всего лишь одно выделение памяти на 256 сообщений (эти сообщения хранятся в одном большом выделенном участке памяти). Если, кроме того, поток сообщений устойчив и без огромных пиков трафика, то количество выделений памяти на критическом пути падает до нуля (выделенные участки памяти не возвращаются, но снова и снова используются повторно).

Усвоенный урок: Есть разница в том, где выполнять оптимизацию. Оптимизация фрагментов кода, которые не находятся на критическом пути, является напрасной тратой усилий.

24.5. Выделение памяти

Если предположить, что вся инфраструктура инициализирована и соединение между двумя конечными точками уже установлено, есть только одно, для чего выделяется память, это - само сообщение. Таким образом, для оптимизации критического пути мы должны были изучить, как происходит выделение памяти под сообщения и как сообщения передаются вверх и вниз по стеку.

В сфере высокопроизводительных сетей общеизвестно, что лучшая производительность достигается за счет четкого баланса между стоимостью выделения памяти под сообщение и стоимостью копирования сообщения (например, http://hal.inria.fr/docs/00/29/28/31/PDF/Open-MX-IOAT.pdf: сравните различные подходы для «малых», «средних» и «больших» сообщений). Для небольших сообщений, копирование гораздо дешевле, чем выделение памяти. Имеет смысл вообще не выделять никаких новых кусков памяти, а вместо этого по мере необходимости копировать сообщение в заранее выделенную память. Для больших сообщений, с другой стороны, копирование гораздо дороже, чем выделение памяти. Имеет смысл один раз выделить память для сообщения и, вместо копирования данных, передать указатель на выделенный блок. Такой подход называется «нулевым копированием».

ØMQ обрабатывает оба варианта прозрачно. Сообщение ØMQ представлено структурой, в которой спрятаны детали. Содержимое очень маленьких сообщений хранится непосредственно в структуре. Таким образом, при создании копии структуры действительно копируется данные сообщения. Когда сообщение станет большим, для него выделяется отдельный буфер, а в структуре хранится только указатель на буфер. Создание копии структуры не приводит к копированию данных сообщения, что разумно в случае, когда размер сообщения равен мегабайтам (рис. 24.3). Следует отметить, что в последнем случае в буфере подсчитывается количество указателей, так что можно использовать указатели из нескольких структур и не требуется копировать данные.

Рис.24.3: Копирование сообщения (или только указателя)

Усвоенный урок: Когда вы думаете о производительности, то не считайте, что наилучшее решение только одно. Может случиться, что есть несколько подклассов проблемы (например, небольшие сообщения и большие сообщения), для каждого из которых есть свой собственный оптимальный алгоритм.

24.6. Пакетная обработка

Как уже было упомянуто, огромное количество системных вызовов в системе обмена сообщениями может привести к возникновению узких мест по производительности. На самом деле, проблема гораздо более общая. Возникают очень нетривиальные потери производительности, связанные с обходом стека вызовов и, следовательно, при создании высокопроизводительных приложений разумно настолько, насколько это возможно, избегать выполнение обхода стека вызовов.

Рассмотрим рис.24.4. Чтобы отправить четыре сообщения, вы должны четыре раза пройти весь сетевой стек целиком (т.е. ØMQ, glibc, границу пользовательского пространства/пространства ядра, реализацию TCP, реализацию IP, слой Ethernet, сам NIC и снова вернуться).

Рис.24.4: Отправка четырех сообщений

Тем не менее, если вы решите объединить эти четыре сообщения в один пакет, то потребуется только один обход стека (рис. 24.5). Влияние на пропускную способность сообщений может быть огромным: до двух порядков, особенно если сообщения маленькие и сотни таких сообщений можно упаковывать в один пакет.

Рис.24.5: Пакетная обработка сообщений

С другой стороны, пакетная обработка может иметь негативное влияние на задержку. Возьмем, например, хорошо известный алгоритм Нэйгла (Nagle), который реализован в TCP. Он задерживает исходящие сообщения в течение определенного количества времени и объединяет все накопленные данные в одном пакете. Очевидно, что полная задержка первого сообщения в пакете гораздо больше, чем задержка последнего. Поэтому обычно, чтобы в приложениях снизить задержку, алгоритм Нэйгла отключается. Обычно отключается даже пакетная обработка на всех уровнях стека (например, возможность объединения прерываний NIC).

Но опять же, отсутствие пакетной обработки не означает больших перемещений по стеку и не приводит к низкой пропускной способности сообщений. Мы, кажется, столкнулись с дилеммой между пропускной способностью и задержкой.

Библиотека ØMQ пытается обеспечить сравнительно низкие задержки в сочетании с высокой пропускной способностью за счет использования следующей стратегии: когда поток сообщений небольшой и не превышает пропускную способность сетевого стека, ØMQ отключает всю пакетную обработку с тем, чтобы улучшить задержку. Компромисс здесь в несколько большем использовании ЦП - мы все еще должны часто проходить через стек. Однако в большинстве случаев это не является проблемой.

Когда скорость сообщений превышает пропускную способность сетевого стека, сообщения должны быть поставлены в очередь и храниться в памяти до тех пор, пока стек не будет готов принять их. Очередь означает, что задержка будет расти. Если сообщение находится в очереди одну секунду, то полная задержка будет равна, по меньшей мере, одну секунду. Что еще хуже, поскольку размер очереди растет, задержка будет постепенно увеличиваться. Если размер очереди не ограничен, то задержка может быть больше любого заранее заданного предела.

Было обнаружено, что даже если сетевой стек настроен на минимально возможную задержку (выключен алгоритм Нэйгла, выключено объединение прерываний NIC и т.д.) задержка все еще может быть достаточно большой из-за эффекта очереди, описанного выше.

В такой ситуации имеет смысл агрессивно начинать использование пакетной обработки. Нет ничего, чтобы можно было потерять, поскольку в любом случае задержка и так уже высока. С другой стороны, агрессивные использование пакетной обработки увеличивает пропускную способность и может убрать из очереди ожидающие сообщения, что в свою очередь означает, что задержка будет постепенно падать поскольку задержка из-за очереди уменьшается. Как только в очереди станет мало сообщений, пакетная обработка может быть отключена для еще большего снижения задержки.

Еще одно наблюдение состоит в том, что пакетная обработка должна выполняться только на самом верхнем уровне. Если сообщения группируются там, нижние слои так или иначе не имеют никакого отношения к пакетной обработке, поскольку все алгоритмы пакетной обработки ничего не делают, кроме как вводят дополнительную задержку.

Усвоенный урок: Длят ого, чтобы в асинхронной системе получить оптимальную пропускную способность в сочетании с оптимальным временем ответа, выключите все алгоритмы пакетной обработки на низких уровнях стека и включите пакетную обработку на самом верхнем уровне. Пакетная обработка требуется только тогда, когда новые данные прибывают быстрее, чем они могут быть обработаны.

24.7. Общий обзор архитектуры

До этого момента мы сосредоточили внимание на общих принципах, которые делают библиотеку ØMQ быстрой. Теперь мы взглянем на реальную архитектура системы (рис. 24.6).

Рис.24.6: Архитектура ØMQ

Пользователь взаимодействует с ØMQ с использованием так называемых «сокетов». Они очень похожи на сокеты TCP, основное отличие в том, что каждый сокет может обрабатывать соединения с несколькими абонентами, что немного похоже на то, как это делают несвязанные сокеты UDP.

Объект сокета живет в потоке пользователя (смотрите обсуждение потоковых моделей в следующем разделе). Кроме этого, ØMQ работает в нескольких рабочих потоках, которые обрабатывают асинхронную часть соединения: чтение данных из сети, помещение сообщений в очередь, прием поступающих соединений и т.д.

Существуют различные объекты, находящиеся в рабочих потоках. Каждый из этих объектов принадлежит точно только одному родительскому объекту (принадлежность на диаграмме обозначается простой сплошной линией). Родительский объект может находиться в потоке, отличном от потока потомка. Большинство объектов принадлежат непосредственно сокетам; однако, есть несколько случаев, когда объект находится в собственности объекта, который принадлежит сокету. Все, что мы получаем, это дерево объектов, причем по одному такому дереву на сокет. Дерево используется в ходе завершения работы с сокетами; работа ни с одним из объектов не может быть завершена прежде, чем будет завершена работа со всеми его потомками. Таким образом, мы можем гарантировать, что процесс завершения будет работать так, как ожидается, например, ожидающие исходящие сообщения будут отправлены в сеть до завершения процесса отправки.

Грубо говоря, есть два вида асинхронных объектов; есть объекты, которые не участвуют в передаче сообщений, и есть объекты, которые участвуют. Объекты первого вида связаны главным образом с управлением соединением. Например, объект слушателя TCP (листенер) прослушивает входящие соединения TCP и создает объекты движка/сеанса для каждого нового соединения. Аналогичным образом объект коннектора TCP (соединение) пытается подключиться к пиру TCP и, в случае успеха, создает объект движка/сеанса для управления подключением. Если соединение было разорвано, то объект коннектора пытается восстановить его (реконнектор).

Объекты второго вида представляют собой объекты, которые непосредственно участвуют в передаче данных. Эти объекты состоят из двух частей: сессионный объект (session object) отвечает за взаимодействие с сокетом ØMQ, а объект движка (engine object) отвечает за связь с сетью. Имеется только один вид сессионного объекта, но для каждого протокола, который поддерживается в ØMQ, имеются различные типы объектов движков. Т.е., у нас есть движки TCP, движки IPC (межпроцессное взаимодействие), движки PGM (надежный мультикастовый протокол — смотрите RFC 3208) и др. Набор движков можно расширять - в будущем мы можем выбрать для реализации, скажем, движок WebSocket или движок SCTP.

Сессии являются сеансами обмена сообщениями с сокетами. Есть два направления для передачи сообщений и каждое направление обрабатывается при помощи конвейерного объекта. Каждый конвейер является в своей основе очередью без блокировок, оптимизированной для быстрого прохождения сообщений между потоками.

Наконец, есть объект контекста (о нем рассказывалось в предыдущих разделах, но он не показан на рисунке), в котором хранится глобальное состояние и он доступен всем сокетам и всем асинхронным объектам.

24.8. Модель распараллеливания

Одним из требований для ØMQ было возможность использования многоядерных устройств; другими словами, чтобы можно масштабировать пропускную линейно с увеличением числа доступных ядер процессора.

Наш предыдущий опыт работы с системами обмена сообщениями показал, что с использованием нескольких потоков в классическом пути (критические секции, семафоры и т.д.) не дает значительного улучшения производительности. В самом деле, многопоточная версия системы обмена сообщениями может быть более медленной, чем однопоточная, даже если измеренная осуществляется на многоядерном устройстве. Отдельные потоки просто тратят слишком много времени на ожидание друг друга, и в то же время требуют большого количества переключений контекста, что замедляет работу системы.

Учитывая эти проблемы, мы решили перейти на другую модель. Цель состояла в том, чтобы полностью избежать блокировок и позволить каждому потоку работать на полной скорости. Взаимодействие между потоками было реализовано с помощью асинхронных сообщений (события), которые передаются между потоками. Это, как знают инсайдеры, является классической моделью актера (actor model).

Идея заключалась в том, чтобы запускать на каждом ядре процессора по одному рабочему потоку — наличие двух потоков, совместно использующих то же самое ядро, будет означать лишь большое количество переключений контекста без получения особых преимуществ. Каждый внутренний объект ØMQ, такой, как, скажем, движок TCP, будет тесно связан с конкретным рабочим потоком. Это, в свою очередь, означает, что нет никакой необходимости в критических секциях, взаимоисключаемых событиях (mutexes), семафорах и тому подобном. Кроме того, эти объекты ØMQ не будут перераспределяться между ядрами процессора, так что удастся избежать негативного влияния на производительность, связанного с загрязнением кэша (рис.24.7).

Рис.24.7: Несколько рабочих потоков

Благодаря такой конструкции исчезает много традиционных многопоточных проблем. Тем не менее, есть необходимость в том, чтобы рабочим потоком могли пользоваться множество объектов, что в свою очередь означает, что должен быть какой-то вид кооперативной многозадачности. Это означает, что нам нужен планировщик; объекты должны управляться при помощи событий, не надо реализовывать управление циклом всех событий; мы должны учесть возможность возникновения событий в произвольной последовательности, причем даже очень редких; мы должны обеспечить, чтобы ни один объект не удерживал процессор слишком долго и т.д.

Короче говоря, вся система должна стать полностью асинхронной. Никакой объект не должен выполнять блокирующих операций, поскольку он заблокирует не только себя, но также и все другие объекты, использующие тот же самый рабочий поток. Все объекты должны представлять собой, прямо или косвенно, автоматы или машины состояний. При наличии сотен или тысяч машин состояний, работающих параллельно, вы должны обеспечить все возможные взаимодействия между ними и, самое главное, правильно реализовать процесс завершения их работы.

Получается, что завершение работы полностью асинхронной системы является в чистом виде устрашающе сложной задачей. При попытке завершить работу тысячи движущихся частей, некоторые из которых работают, некоторые находятся в состоянии ожидания, некоторые - в процессе инициализации, некоторые из них уже завершили свою работу самостоятельно, возможны возникновения всех видов состояний гонки, утечки ресурсов и тому подобное. Подсистема завершения работы является, безусловно, самой сложной частью ØMQ. Быстрый просмотр трекера ошибок показывает, что около 30 - 50% обнаруженных ошибок связаны в той или иной форме с этапом завершения работы системы.

Усвоенный урок: Когда стремитесь к экстремальной производительности и масштабируемости, то рассмотрите модель актера; это чуть ли не единственная вариант в подобных случаях. Однако, если вы не пользуетесь специализированной системой, например, Erlang или самой ØMQ, вам придется написать и вручную отладить инфраструктуру большого объема. Кроме того, с самого начала подумайте о процедуре завершения работы системы. Это будет самая сложная часть кода, и если у вас нет четкого представления о том, как ее реализовать, вам, вероятно, следует в первую очередь пересмотреть использование модели актера.

24.9. Неблокирующие алгоритмы

В последнее время в моде стали неблокирующие алгоритмы. Это простые механизмы межпотокового взаимодействия, в которых не используются предоставляемые ядром примитивы синхронизации, такие как взаимоисключаемые события или семафоры; они предпочитают выполнять синхронизацию с использованием атомарных операций процессора, таких как атомное сравнение и своп (CAS). Следует иметь в виду, что они не в буквальном смысле работают без блокировок — в действительности блокировки происходят за кулисами на аппаратном уровне.

ØMQ использует неблокирующую очередь в конвейерных объектах для передачи сообщений между потоками пользователя и рабочими потоками ØMQ. Есть два интересных аспекта, касающихся того, как ØMQ использует неблокирующую очередь.

Во-первых, в каждой очередь есть ровно один поток, который осуществляется запись, и ровно один поток, который осуществляет чтение. Если необходима связь типа «1-N», то создается несколько очередей (рис.24.8). Благодаря такой организации очереди не надо беспокоиться о о синхронизации записи (есть только один поток, осуществляющий запись) или чтения (есть только один поток, осуществляющий чтение), причем очередь может быть реализована очень эффективным способом.

Рис.24.8: Очереди

Во-вторых, мы поняли, что хотя неблокирующие алгоритмы более эффективны, чем классические алгоритмы, базирующиеся на использовании взаимоисключаемых событиях, атомарные операции процессора все еще достаточно дороги (особенно когда есть рассогласованность между ядрами процесса) и выполнение атомарной операции для каждого сообщения, которое записывается или читается, происходит гораздо медленнее, чем нам это могло подойти.

Способ ускорения — опять же - потоковая обработка. Представьте, что у вас есть 10 сообщений, которые должны быть записаны в очередь. Это может произойти, например, когда вы получили сетевой пакет, содержащий 10 небольших сообщений. Получение пакета является атомарным событием, вы не можете получить половину пакета. В результате этого атомарного события в неблокирующую очередь потребуется записать 10 сообщений. Нет никакого смысла выполнять атомарную операцию для каждого сообщения. Вместо этого, вы можете накопить сообщения в виде порции «предзаписи» в той части очереди, которая доступна исключительно для записывающего потока, а затем сбросить ее в очередь с помощью одной атомарной операции.

То же самое относится и к считыванию из очереди. Представьте себе 10 сообщений, рассмотренных выше, которые уже были помещены в в очередь. Поток, осуществляющий чтение, может извлекать каждое сообщение из очереди с использованием атомарной операции. Тем не менее, это перебор; вместо этого, поток может с помощью одной атомарной операции перенести все ожидающие сообщения в порцию «предварительного чтения» в очереди. После этого, он может выбирать сообщения из буфера «предварительного чтения» по одному. Порция «предварительного чтения» принадлежит и доступна исключительно потоку, осуществляющему чтение, и, таким образом, на этой фазе вообще не нужна какая-либо синхронизация.

Стрелка в левой части рисунка 24.9 показывает, как можно с помощью модификации одного указателя выполнить сброс в очередь содержимого буфера предварительной записи. Стрелка в правой части показывает, как можно ничего не делая, а только изменив еще один указатель, сдвинуть содержимое очереди в буфер предварительного чтения.

Рис.24.9: Неблокирующая очередь

Усвоенный урок: неблокирующие алгоритмы трудно придумывать, сложно реализовывать и почти невозможно отлаживать. Если возможно, то используйте существующие проверенные алгоритмы, а не изобретайте свои собственные. Если требуется экстремальная производительность, то не следует полагаться исключительно на неблокирующие алгоритмы. Хотя они и быстрые, производительность можно значительно улучшить, если поверх их сделать умную пакетную обработку.

24.10. Интерфейс API

Пользовательский интерфейс является наиболее важной частью любого продукта. Это единственная часть вашей программы, которая видна внешнему миру, и если вы сделаете ее неправильно, то мир будет ненавидеть вас. В конечном изделии это либо графический интерфейс, либо интерфейс командной строки. В библиотеках - это интерфейс API.

В ранних версиях ØMQ интерфейс API базировался на модели обменов и очередей AMQP (смотрите спецификации AMQP). С исторической точки зрения было бы интересно взглянуть на документ white paper from 2007, в котором делается попытка примирить AMQP с бесброкерной моделью обмена сообщениями. Я потратил окончание 2009 года на его переписывание почти с нуля, чтобы вместо этого использовать интерфейс BSD Socket API. Это был поворотный момент; с этого момента количество применений библиотеки ØMQ взлетело. Если раньше это был нишевый продукт, используемый только горсткой экспертов по сообщениям, то после этого он стал обычным инструментом, удобным для любого. Через год или около того размер сообщества увеличился в десять раз, было реализовано несколько привязок к 20 различным языкам и т.д.

Пользовательский интерфейс определяет восприятие продукта. При практически оставшихся тех же самых функциональных возможностях - просто за счет изменения интерфейса API - ØMQ превратился из продукта уровня «enterprise messaging» (промышленной системы обмена сообщения) в «сетевой» продукт. Иными словами, восприятие изменилось со «сложной части инфраструктуры для крупных банков» на то, что «это помогает мне отправить мое сообщение длиной в 10 байт из приложения A в приложение B».

Усвоенный урок: Разберитесь с тем, каким, как вы хотите, должен быть ваш проект, и проектируйте соответствующий пользовательский интерфейс. Если у вас есть пользовательский интерфейс, который не совпадает с видением проекта, то это является 100% гарантией движения к провалу.

Одним из важных аспектов перехода на интерфейс BSD Sockets API было то, что он не был революционным недавно изобретенным API, а уже существовал и был хорошо известен. На самом деле, интерфейс BSD Sockets API является одним из старейших API, которым сегодня по-прежнему активно пользуются; он восходит к 1983 году и к 4.2BSD Unix. Он широко распространен и стабилен в течение буквально десятилетий.

Вышеуказанный факт дает много преимуществ. Во-первых, это интерфейс API, который известен каждому, поэтому кривая обучения будет до неприличия плоской. Даже если вы никогда не слышали о ØMQ, вы можете создать свое первое приложение через пару минут благодаря тому, что вы можете воспользоваться знаниями о BSD Sockets.

Во-вторых, использование широко распространенного интерфейса API позволяет интегрировать ØMQ с существующими технологиями. Например, представление объектов ØMQ в виде «сокетов» или «дескрипторов файлов» позволяет в одном и том же цикле событий выполнять обработку событий TCP, UDP, конвейеров, файлов и ØMQ. Другой пример: экспериментальный проект по переносу функций, похожих на ØMQ, в ядро Linux оказывается в реализации довольно простым. За счет применения того же самого по концептуальности фреймворка уже сейчас можно пользоваться большей частью инфраструктуры ØMQ.

Третьим и, вероятно, самым главным, является тот факт, что поскольку интерфейс BSD Sockets API выжил в течение почти трех десятилетий несмотря на многочисленные попытки его заменить, это означает, что в нем самом изначально есть нечто. Разработчики BSD Sockets API, намеренно или случайно, приняли правильные проектные решения. Использовав это API, мы можем автоматически воспользоваться этими проектными решениями даже не зная, какими они были и какие проблемы они решали.

Усвоенный урок: Хотя интерес к повторному использованию кода был повышен с незапамятных времен, а позже к нему присоединилось повторное использование щаблонов, важно думать о повторном использовании решений в еще более общем виде. Когда разрабатываете продукт, взгляните на аналогичные продукты. Проверьте, какие не удались, а какие оказались успешными; изучите успешные проекты. Не поддавайтесь синдрому «Здесь ничего не придумано». Повторно используйте идеи, интерфейсы API, концептуальные фреймворки, которые вы посчитаете подходящими. Поступая таким образом, вы позволяете пользователям повторно использовать имеющиеся у них знания. В то же время вы можете избежать технических ловушек даже в случае, если вы на данный момент их не осознаете.

24.11. Шаблоны обмена сообщениями

В любой системе обмена сообщениями, наиболее важной проблемой проекта является то, каким способом пользователю приходится указывать какие сообщения направляются и в каком направлении. Существуют два основных подхода, и я считаю, что такая дихотомия вполне универсальна и применима практически для любой проблемы, возникающей в области программного обеспечения.

Один из подходов заключается в принятии философии Unix «делать одно и делать это хорошо». Это означает, что область проблемы должна быть искусственно ограничена небольшой и хорошо понятной частью. Затем программа должна решить эту ограниченную задачу правильно и исчерпывающее. Примером такого подхода в сфере обмена сообщениями является MQTT. Это протокол распределенных сообщений для группы потребителей. Он не может использоваться ни для чего другого (скажем, для RPC), но он прост в использовании и хорошо работает с распределенными сообщениями.

В другом подходе мы ориентируемся на общность и предоставляем мощную и глубоко настраиваемую систему. Примером такой системы является AMQP. Его модель очередей и обменов сообщениями предоставляет пользователям средства для программного определения практически любого алгоритма маршрутизации, который они могут придумать. Компромиссом, конечно, является множество параметров, о которых нужно позаботиться.

В ØMQ выбрана первая модель, поскольку в ней предполагается, что полученным продуктом сможет пользоваться в основном каждый, тогда как обобщенная модель для того, чтобы ей пользоваться, требует экспертов в области обмена сообщений. Чтобы это продемонстрировать, давайте посмотрим, как модель влияет на сложность API. Далее приведена реализация клиентской части RPC, построенная поверх обобщенной системы (AMQP):

connect ("192.168.0.111")
exchange.declare (exchange="requests", type="direct", passive=false,
    durable=true, no-wait=true, arguments={})
exchange.declare (exchange="replies", type="direct", passive=false,
    durable=true, no-wait=true, arguments={})
reply-queue = queue.declare (queue="", passive=false, durable=false,
    exclusive=true, auto-delete=true, no-wait=false, arguments={})
queue.bind (queue=reply-queue, exchange="replies",
    routing-key=reply-queue)
queue.consume (queue=reply-queue, consumer-tag="", no-local=false,
    no-ack=false, exclusive=true, no-wait=true, arguments={})
request = new-message ("Hello World!")
request.reply-to = reply-queue
request.correlation-id = generate-unique-id ()
basic.publish (exchange="requests", routing-key="my-service",
    mandatory=true, immediate=false)
reply = get-message ()

С другой стороны, ØMQ разбивает ландшафт обмена сообщениями на так называемые «шаблоны сообщений». Примерами шаблонов являются «публикация/подписка», «запрос/ответ» или «распараллеливаемый конвейер». Каждый шаблон обмена сообщениями полностью ортогонален другим шаблонам и может рассматриваться как отдельный инструмент.

Далее приведена еще одна реализация вышеприведенного приложения, выполненная в ØMQ с использованием шаблона «запрос/ответ». Обратите внимание на то, что все настройки сводятся к одному шагу выбора правильного шаблона обмена сообщениями («REQ»):

s = socket (REQ)
s.connect ("tcp://192.168.0.111:5555")
s.send ("Hello World!")
reply = s.recv ()

До этого момента мы утверждали, что конкретные решения лучше, чем обобщенные. Мы хотим, чтобы наши решения, чтобы быть как можно более конкретными. Тем не менее, мы одновременно хотим, насколько это окажется возможным, предложить нашим клиентам максимально широкий спектр функциональных возможностей. Как мы можем решить это кажущееся противоречие?

Ответ состоит из двух шагов:

  1. Определение слоя стека, который касается конкретной проблемной области (например, транспорт, маршрутизация, презентация и т.д.).
  2. Предоставление нескольких реализаций слоя. Это должны быть непересекающиеся реализации, отдельные для каждого варианта использования.

Давайте посмотрим на пример транспортного уровня стека Internet. Он предназначен для предоставления таких сервисов, как передача потоков данных, управления потоками, обеспечение надежности передачи и т.д., которые реализуются поверх сетевого уровня (IP). Он реализуется в соответствие с определением нескольких непересекающихся решений: TCP для соединений, ориентированных на надежную передачу потока, UDP для соединения с ненадежной пакетной передачей, SCTP для передачи нескольких потоков, DCCP для ненадежных соединений и так далее.

Обратите внимание, что каждая реализация полностью ортогональна: конечная точка UDP не может обмениваться сообщениями с конечной точкой TCP. А конечная точка SCTP не может обмениваться сообщениями с конечной точкой DCCP. Это означает, что в любой момент к стеку могут быть добавлены новые реализации без влияния на существующие части стека. И наоборот, о неудачных реализациях можно будет забыть и можно будет выбросить их без ущерба для жизнеспособности транспортного уровня в целом.

Тот же принцип относится и к шаблонам обмена сообщениями так, как это сделано в ØMQ. Шаблоны обмена сообщениями формируют слой (так называемый «слой масштабируемости») поверх транспортного уровня (TCP и аналоги). Реализациями этого слоя являются индивидуальные шаблоны обмена сообщениями. Они строго ортогональны — конечная точка «публикации/подписки» не может обмениваться сообщениями с конечной точкой «запроса/ответа» и т.д. Строгое разделение между шаблонами в свою очередь означает, что по мере необходимости могут быть добавлены новые шаблоны и что неудачные эксперименты с новыми шаблонами не повредят существующим шаблонам.

Усвоенный урок: Когда решается сложная и многогранная проблема, то может оказаться, что монолитное обобщенное решение может оказаться не лучшим. Вместо этого, мы можем рассмотреть проблемную область в виде абстрактного слоя и предложить несколько его реализаций, каждая из которых направлена на вполне конкретные условия использования. Когда вы так поступаете, то очень тщательно определите условия использования. Проверьте, что попадает в эту область, а что - нет. Из-за слишком агрессивного ограничения области использования, применение программного обеспечения может быть ограничено. Но если вы определите проблему слишком широко, продукт может стать слишком сложным, нечетким и запутанным для пользователей.

24.12. Заключение

Поскольку наш мир заселяется большим количеством маленьких компьютеров, подключаемых через Интернет - мобильными телефонами, считывателями меток RFID, планшетами и ноутбуками, устройствами GPS и т. д. - проблема распределенных вычислений перестает быть областью академической науки и становится повседневной проблемой, которую решает каждый разработчик. Решения, к сожалению, в основном являются предметно-ориентированными хакерскими трюками. Эта статья подытоживает наш опыт построения крупномасштабной распределенной системы на систематической основе. Она фокусируется на проблемах, которые представляют интерес с точки зрения архитектуры программ, и мы надеемся, что разработчики и программисты из сообщества сторонников открытого кода посчитают ее полезной.

Creative Commons. Перевод был сделан в соответствие с лицензией Creative Commons. С русским вариантом лицензии можно ознакомиться здесь.